@skyhook-io/radar-app 1.15.0 → 1.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +14 -11
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +247 -201
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +223 -0
- package/src/api/radarFeatures.test.ts +142 -0
- package/src/api/radarFeatures.ts +148 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/components/CloudFunnelButton.tsx +9 -9
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +5 -6
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +382 -521
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +20 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +57 -12
- package/src/components/workload/WorkloadView.tsx +44 -6
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
|
@@ -6,14 +6,17 @@ import { TrafficWizard } from './TrafficWizard'
|
|
|
6
6
|
import { TrafficGraph, type TrafficGraphSelection } from './TrafficGraph'
|
|
7
7
|
import { TrafficFilterSidebar } from './TrafficFilterSidebar'
|
|
8
8
|
import { TrafficFlowListProvider } from './TrafficFlowListContext'
|
|
9
|
-
import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle } from 'lucide-react'
|
|
9
|
+
import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle, Clock } from 'lucide-react'
|
|
10
10
|
import { clsx } from 'clsx'
|
|
11
11
|
import { useQueryClient } from '@tanstack/react-query'
|
|
12
12
|
import { useDock } from '../dock'
|
|
13
13
|
import { AlertBanner, EmptyState, PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
|
|
14
14
|
import { useConnection } from '../../context/ConnectionContext'
|
|
15
15
|
import { Tooltip } from '../ui/Tooltip'
|
|
16
|
-
import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind } from './trafficFilters'
|
|
16
|
+
import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind, mergeFlowVolume, coverageLabel } from './trafficFilters'
|
|
17
|
+
|
|
18
|
+
// Consecutive 2s retries of an empty result that came with a transient warning.
|
|
19
|
+
const MAX_EMPTY_RETRIES = 5
|
|
17
20
|
|
|
18
21
|
// Addon types for filtering
|
|
19
22
|
export type AddonMode = 'show' | 'group' | 'hide'
|
|
@@ -442,19 +445,34 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
442
445
|
// an attribute it does not export, traffic it cannot orient. Retrying returns
|
|
443
446
|
// the same answer, so retrying forever is all cost and no progress.
|
|
444
447
|
const warningIsPermanent = flowsData?.warningKind === 'partial'
|
|
445
|
-
|
|
446
|
-
//
|
|
448
|
+
const coverage = coverageLabel(flowsData?.coveredSince, flowsData?.timestamp)
|
|
449
|
+
// An 'incomplete' one is a fetch that worked but could not see everything
|
|
450
|
+
// (events lost, nodes unreachable). Not a failure, so not retried at once.
|
|
451
|
+
const warningIsIncomplete = flowsData?.warningKind === 'incomplete'
|
|
452
|
+
|
|
453
|
+
// Auto-retry when flows return with warning but no data (e.g., port-forward not
|
|
454
|
+
// ready yet). Bounded: a warning that keeps coming back — a node the relay
|
|
455
|
+
// cannot reach, a query that keeps failing — is the answer rather than a hiccup,
|
|
456
|
+
// and polling it every 2s forever costs the source a burst of queries each time.
|
|
457
|
+
const emptyRetriesRef = useRef(0)
|
|
458
|
+
// A different question gets its own retries.
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
emptyRetriesRef.current = 0
|
|
461
|
+
}, [namespaces, timeRange])
|
|
447
462
|
useEffect(() => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
) {
|
|
454
|
-
const timer = setTimeout(() =>
|
|
463
|
+
const empty = !flowsData?.aggregated || flowsData.aggregated.length === 0
|
|
464
|
+
if (!flowsData?.warning || !empty) {
|
|
465
|
+
emptyRetriesRef.current = 0
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
if (!warningIsPermanent && !warningIsIncomplete && !flowsFetching && emptyRetriesRef.current < MAX_EMPTY_RETRIES) {
|
|
469
|
+
const timer = setTimeout(() => {
|
|
470
|
+
emptyRetriesRef.current += 1
|
|
471
|
+
refetchFlowsRaw()
|
|
472
|
+
}, 2000)
|
|
455
473
|
return () => clearTimeout(timer)
|
|
456
474
|
}
|
|
457
|
-
}, [flowsData, warningIsPermanent, flowsFetching, refetchFlowsRaw])
|
|
475
|
+
}, [flowsData, warningIsPermanent, warningIsIncomplete, flowsFetching, refetchFlowsRaw])
|
|
458
476
|
|
|
459
477
|
// Filter flows based on user preferences
|
|
460
478
|
// Note: namespace filtering is done server-side via the global namespace selector
|
|
@@ -765,17 +783,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
765
783
|
|
|
766
784
|
const existing = aggregatedMap.get(key)
|
|
767
785
|
if (existing) {
|
|
768
|
-
|
|
769
|
-
existing.connections += flow.connections
|
|
770
|
-
existing.bytesSent += flow.bytesSent
|
|
771
|
-
existing.bytesRecv += flow.bytesRecv
|
|
772
|
-
existing.flowCount += flow.flowCount
|
|
773
|
-
if (flow.requestCount) {
|
|
774
|
-
existing.requestCount = (existing.requestCount || 0) + flow.requestCount
|
|
775
|
-
}
|
|
776
|
-
if (flow.errorCount) {
|
|
777
|
-
existing.errorCount = (existing.errorCount || 0) + flow.errorCount
|
|
778
|
-
}
|
|
786
|
+
mergeFlowVolume(existing, flow)
|
|
779
787
|
// Everything merged here shares the key's direction-known state, so the
|
|
780
788
|
// flag is already correct on the entry that was created first.
|
|
781
789
|
} else {
|
|
@@ -819,11 +827,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
819
827
|
|
|
820
828
|
const existing = internetFlowsMap.get(destKey)
|
|
821
829
|
if (existing) {
|
|
822
|
-
|
|
823
|
-
existing.connections += flow.connections
|
|
824
|
-
existing.bytesSent += flow.bytesSent
|
|
825
|
-
existing.bytesRecv += flow.bytesRecv
|
|
826
|
-
existing.flowCount += flow.flowCount
|
|
830
|
+
mergeFlowVolume(existing, flow)
|
|
827
831
|
} else {
|
|
828
832
|
// Create new "Internet" → destination flow
|
|
829
833
|
internetFlowsMap.set(destKey, {
|
|
@@ -852,6 +856,8 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
852
856
|
// Track totals for aggregated edges
|
|
853
857
|
let addonInternetTotal = 0
|
|
854
858
|
let addonToK8sTotal = 0
|
|
859
|
+
let addonInternetRate = 0
|
|
860
|
+
let addonToK8sRate = 0
|
|
855
861
|
const processedFlows: AggregatedFlow[] = []
|
|
856
862
|
|
|
857
863
|
// Check if destination is the kubernetes API server
|
|
@@ -868,6 +874,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
868
874
|
// Internet → Addon: aggregate into single edge to group
|
|
869
875
|
if (sourceIsInternet && destIsAddon) {
|
|
870
876
|
addonInternetTotal += flow.connections
|
|
877
|
+
addonInternetRate += flow.requestRate ?? 0
|
|
871
878
|
processedFlows.push({
|
|
872
879
|
...flow,
|
|
873
880
|
source: {
|
|
@@ -880,6 +887,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
880
887
|
// Addon → Kubernetes API: aggregate into single edge from group
|
|
881
888
|
else if (sourceIsAddon && destIsK8sAPI) {
|
|
882
889
|
addonToK8sTotal += flow.connections
|
|
890
|
+
addonToK8sRate += flow.requestRate ?? 0
|
|
883
891
|
processedFlows.push({
|
|
884
892
|
...flow,
|
|
885
893
|
destination: {
|
|
@@ -909,6 +917,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
909
917
|
protocol: 'tcp',
|
|
910
918
|
port: 0,
|
|
911
919
|
connections: addonInternetTotal,
|
|
920
|
+
...(addonInternetRate > 0 && { requestRate: addonInternetRate }),
|
|
912
921
|
bytesSent: 0,
|
|
913
922
|
bytesRecv: 0,
|
|
914
923
|
flowCount: 1,
|
|
@@ -932,6 +941,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
932
941
|
protocol: 'tcp',
|
|
933
942
|
port: 443,
|
|
934
943
|
connections: addonToK8sTotal,
|
|
944
|
+
...(addonToK8sRate > 0 && { requestRate: addonToK8sRate }),
|
|
935
945
|
bytesSent: 0,
|
|
936
946
|
bytesRecv: 0,
|
|
937
947
|
flowCount: 1,
|
|
@@ -1079,7 +1089,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1079
1089
|
}
|
|
1080
1090
|
|
|
1081
1091
|
return (
|
|
1082
|
-
<TrafficFlowListProvider flows={listFlows} graphSelection={graphSelection} clearSelection={() => setGraphSelection(null)}>
|
|
1092
|
+
<TrafficFlowListProvider flows={listFlows} responsesCallerOriented={flowsData?.l7ResponsesCallerOriented === true} graphSelection={graphSelection} clearSelection={() => setGraphSelection(null)}>
|
|
1083
1093
|
<div className="flex h-full w-full">
|
|
1084
1094
|
{/* Sidebar */}
|
|
1085
1095
|
<TrafficFilterSidebar
|
|
@@ -1207,6 +1217,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1207
1217
|
</button>
|
|
1208
1218
|
</Tooltip>
|
|
1209
1219
|
)}
|
|
1220
|
+
{coverage && (
|
|
1221
|
+
// Not a warning: a busy cluster reaches the limit on every
|
|
1222
|
+
// fetch. It says which part of the window the map shows.
|
|
1223
|
+
<Tooltip content={`Radar reads at most ${(flowsData?.nodeFlowLimit ?? 0).toLocaleString()} of the newest flows from each node. At least one node reached that limit before the start of the ${timeRange} window, so its earlier traffic is not included. Other nodes may still show older flows.`}>
|
|
1224
|
+
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-secondary tabular-nums">
|
|
1225
|
+
<Clock className="w-3 h-3" /> {coverage} of {timeRange}
|
|
1226
|
+
</div>
|
|
1227
|
+
</Tooltip>
|
|
1228
|
+
)}
|
|
1210
1229
|
<div className="flex items-center px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-tertiary tabular-nums">
|
|
1211
1230
|
{flowStats.shown}/{flowStats.total}
|
|
1212
1231
|
</div>
|
|
@@ -1233,7 +1252,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1233
1252
|
/>
|
|
1234
1253
|
) : finalFlows.length > 0 ? (
|
|
1235
1254
|
<>
|
|
1236
|
-
{flowsData?.warning &&
|
|
1255
|
+
{flowsData?.warning && (
|
|
1237
1256
|
// Sits below the two chip rows (both top-3) rather than beside
|
|
1238
1257
|
// them: centred at that height it would cover the flow count and
|
|
1239
1258
|
// the refresh control at common widths. role/aria-live because it
|
|
@@ -1245,12 +1264,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1245
1264
|
>
|
|
1246
1265
|
<AlertBanner
|
|
1247
1266
|
variant="warning"
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
// "
|
|
1252
|
-
//
|
|
1253
|
-
|
|
1267
|
+
// The title follows the kind. A partial warning is about values
|
|
1268
|
+
// on edges that are shown — a port reported as 0, UDP as TCP, a
|
|
1269
|
+
// 5xx rate that failed to load — so it says "unreliable", not
|
|
1270
|
+
// "incomplete", which would send the reader looking for missing
|
|
1271
|
+
// workloads. An incomplete or transient one beside flows is
|
|
1272
|
+
// exactly that: a stream cut short, events lost, a node the
|
|
1273
|
+
// relay could not reach, a TCP query that failed — edges may
|
|
1274
|
+
// be missing from what is drawn.
|
|
1275
|
+
title={warningIsPermanent ? 'Some values on this map are unreliable' : 'Some traffic may be missing from this map'}
|
|
1254
1276
|
message={flowsData.warning}
|
|
1255
1277
|
/>
|
|
1256
1278
|
</div>
|
|
@@ -1324,7 +1346,9 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1324
1346
|
headline={
|
|
1325
1347
|
warningIsPermanent
|
|
1326
1348
|
? 'No traffic Radar can place on the map'
|
|
1327
|
-
:
|
|
1349
|
+
: warningIsIncomplete
|
|
1350
|
+
? 'No traffic seen, but some may be missing'
|
|
1351
|
+
: 'Unable to fetch traffic data'
|
|
1328
1352
|
}
|
|
1329
1353
|
body={flowsData.warning}
|
|
1330
1354
|
className="max-w-md"
|
|
@@ -288,8 +288,10 @@ export function TrafficWizard({
|
|
|
288
288
|
</div>
|
|
289
289
|
)}
|
|
290
290
|
|
|
291
|
-
{/* No recommendation
|
|
292
|
-
|
|
291
|
+
{/* No recommendation. Not when a source is installed but unusable:
|
|
292
|
+
its banner above is the answer, and "install Cilium with Hubble"
|
|
293
|
+
would tell the user to install what they already have. */}
|
|
294
|
+
{!recommendation && !sourcesData?.detected.some(s => s.status === 'not_found') && (
|
|
293
295
|
<div className="border border-theme-border rounded-lg p-4 space-y-3">
|
|
294
296
|
<p className="text-sm text-theme-text-secondary">
|
|
295
297
|
No traffic source detected. Install Cilium with Hubble for traffic visibility.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import type { AggregatedFlow, TrafficFlow } from '../../types'
|
|
3
|
+
import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, isExternalKind, isPolicyDropReason, requestRateOf, errorRateOf, formatRate, displayVolume, mergeFlowVolume, dedupeHTTPPairs, coverageLabel, latencyWeightOf } from './trafficFilters'
|
|
3
4
|
|
|
4
5
|
describe('matchesStatusRanges', () => {
|
|
5
6
|
it('does not filter when nothing is selected', () => {
|
|
@@ -128,3 +129,155 @@ describe('isPolicyDropReason', () => {
|
|
|
128
129
|
expect(isPolicyDropReason('', 0)).toBe(false)
|
|
129
130
|
})
|
|
130
131
|
})
|
|
132
|
+
|
|
133
|
+
describe('edge rates', () => {
|
|
134
|
+
it('reads the unrounded rates, so a trickle of errors is not a 100% error rate', () => {
|
|
135
|
+
const flow = { requestRate: 0.3, requestCount: 1, errorRate: 0.01, errorCount: 1 }
|
|
136
|
+
expect(errorRateOf(flow) / requestRateOf(flow)).toBeCloseTo(0.0333, 3)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('falls back to the rounded counts from a server that sends no rates', () => {
|
|
140
|
+
expect(requestRateOf({ requestCount: 7 })).toBe(7)
|
|
141
|
+
expect(errorRateOf({ errorCount: 2 })).toBe(2)
|
|
142
|
+
expect(requestRateOf({})).toBe(0)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('shows the unrounded rate for a rate-based edge, and the connection count otherwise', () => {
|
|
146
|
+
expect(displayVolume({ requestRate: 0.3, connections: 1 }, true)).toBe(0.3)
|
|
147
|
+
expect(displayVolume({ connections: 4 }, true)).toBe(4)
|
|
148
|
+
expect(displayVolume({ requestRate: 0.3, connections: 12 }, false)).toBe(12)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('formats a rate without rounding a trickle away', () => {
|
|
152
|
+
expect(formatRate(0)).toBe('0')
|
|
153
|
+
expect(formatRate(0.004)).toBe('<0.01')
|
|
154
|
+
expect(formatRate(0.3)).toBe('0.30')
|
|
155
|
+
expect(formatRate(2.46)).toBe('2.5')
|
|
156
|
+
expect(formatRate(12.4)).toBe('12')
|
|
157
|
+
expect(formatRate(1530)).toBe('1.5K')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('mergeFlowVolume', () => {
|
|
162
|
+
it('sums every figure the graph displays, not only connections and bytes', () => {
|
|
163
|
+
const base = { source: { name: 'a', namespace: '', kind: 'External' }, destination: { name: 'web', namespace: 'shop', kind: 'Pod' }, protocol: 'tcp', port: 80, lastSeen: '' }
|
|
164
|
+
const into = { ...base, flowCount: 1, bytesSent: 10, bytesRecv: 20, connections: 2, requestCount: 2, requestRate: 2, errorRate: 0.5, errorCount: 1 }
|
|
165
|
+
mergeFlowVolume(into, { ...base, flowCount: 1, bytesSent: 5, bytesRecv: 5, connections: 8, requestCount: 8, requestRate: 8 })
|
|
166
|
+
expect(into).toMatchObject({ connections: 10, bytesSent: 15, bytesRecv: 25, flowCount: 2, requestCount: 10, requestRate: 10, errorRate: 0.5, errorCount: 1 })
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('mergeFlowVolume latency', () => {
|
|
171
|
+
const base: AggregatedFlow = { source: { name: 'a', namespace: 'shop', kind: 'Pod' }, destination: { name: 'web', namespace: 'shop', kind: 'Pod' }, protocol: 'tcp', port: 80, lastSeen: '', flowCount: 1, bytesSent: 0, bytesRecv: 0, connections: 1 }
|
|
172
|
+
|
|
173
|
+
it('weights average latency by request rate, whatever the order', () => {
|
|
174
|
+
const fast = { ...base, requestRate: 100, avgLatencyMs: 10 }
|
|
175
|
+
const slow = { ...base, requestRate: 1, avgLatencyMs: 1000 }
|
|
176
|
+
const a = { ...fast }
|
|
177
|
+
mergeFlowVolume(a, slow)
|
|
178
|
+
const b = { ...slow }
|
|
179
|
+
mergeFlowVolume(b, fast)
|
|
180
|
+
expect(a.avgLatencyMs).toBeCloseTo(19.8, 1)
|
|
181
|
+
expect(b.avgLatencyMs).toBeCloseTo(19.8, 1)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('ignores traffic that had no latency measured, in any merge order', () => {
|
|
185
|
+
const a = { ...base, requestRate: 1, avgLatencyMs: 10 }
|
|
186
|
+
const b = { ...base, requestRate: 100 }
|
|
187
|
+
const c = { ...base, requestRate: 1, avgLatencyMs: 1000 }
|
|
188
|
+
for (const order of [[a, b, c], [c, b, a], [a, c, b], [b, a, c]]) {
|
|
189
|
+
const into = { ...order[0] }
|
|
190
|
+
for (const f of order.slice(1)) mergeFlowVolume(into, f)
|
|
191
|
+
expect(into.avgLatencyMs).toBeCloseTo(505, 6)
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('keeps the weight through a copy of a merged edge', () => {
|
|
196
|
+
const into = { ...base, requestRate: 1, avgLatencyMs: 1000 }
|
|
197
|
+
mergeFlowVolume(into, { ...base, requestRate: 100 })
|
|
198
|
+
const collapsed = { ...into, source: { name: 'Internet', namespace: '', kind: 'Internet' } }
|
|
199
|
+
mergeFlowVolume(collapsed, { ...base, requestRate: 1, avgLatencyMs: 10 })
|
|
200
|
+
expect(collapsed.avgLatencyMs).toBeCloseTo(505, 6)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('averages edges without rates equally, however many are merged', () => {
|
|
204
|
+
const into = { ...base, avgLatencyMs: 10 }
|
|
205
|
+
mergeFlowVolume(into, { ...base, avgLatencyMs: 20 })
|
|
206
|
+
mergeFlowVolume(into, { ...base, avgLatencyMs: 30 })
|
|
207
|
+
expect(into.avgLatencyMs).toBeCloseTo(20, 6)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('takes the only latency there is', () => {
|
|
211
|
+
const into = { ...base }
|
|
212
|
+
mergeFlowVolume(into, { ...base, avgLatencyMs: 7 })
|
|
213
|
+
expect(into.avgLatencyMs).toBe(7)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('dedupeHTTPPairs', () => {
|
|
218
|
+
const ep = (name: string) => ({ name, namespace: 'shop', kind: 'Pod' })
|
|
219
|
+
const rec = (from: string, to: string, l7Type: string, port = 80): TrafficFlow => ({
|
|
220
|
+
source: ep(from), destination: ep(to), protocol: 'tcp', port, l7Protocol: 'HTTP', l7Type,
|
|
221
|
+
httpMethod: 'GET', httpPath: '/orders', bytesSent: 0, bytesRecv: 0, connections: 1, verdict: 'forwarded', lastSeen: '',
|
|
222
|
+
} as TrafficFlow)
|
|
223
|
+
const rows = (fs: TrafficFlow[]) => fs.map(f => `${f.source.name}>${f.destination.name}:${f.port}:${f.l7Type}`)
|
|
224
|
+
|
|
225
|
+
describe('from a server whose responses run caller → callee', () => {
|
|
226
|
+
it('pairs a response with its request', () => {
|
|
227
|
+
expect(rows(dedupeHTTPPairs([rec('a', 'b', 'REQUEST'), rec('a', 'b', 'RESPONSE')], true))).toEqual(['a>b:80:RESPONSE'])
|
|
228
|
+
})
|
|
229
|
+
it('keeps an unanswered call the other way on the same route', () => {
|
|
230
|
+
expect(rows(dedupeHTTPPairs([rec('a', 'b', 'RESPONSE'), rec('b', 'a', 'REQUEST')], true))).toEqual(['a>b:80:RESPONSE', 'b>a:80:REQUEST'])
|
|
231
|
+
})
|
|
232
|
+
it('keeps an unanswered call on another port', () => {
|
|
233
|
+
expect(rows(dedupeHTTPPairs([rec('a', 'b', 'REQUEST'), rec('a', 'b', 'RESPONSE'), rec('a', 'b', 'REQUEST', 8080)], true)))
|
|
234
|
+
.toEqual(['a>b:80:RESPONSE', 'a>b:8080:REQUEST'])
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('from an older server whose responses run server → client', () => {
|
|
239
|
+
it('pairs the reversed response with its request', () => {
|
|
240
|
+
expect(rows(dedupeHTTPPairs([rec('a', 'b', 'REQUEST'), rec('b', 'a', 'RESPONSE', 41732)], false))).toEqual(['b>a:41732:RESPONSE'])
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('keeps a request that got no response', () => {
|
|
245
|
+
expect(rows(dedupeHTTPPairs([rec('a', 'b', 'REQUEST')], true))).toEqual(['a>b:80:REQUEST'])
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('merged latency', () => {
|
|
250
|
+
const base: AggregatedFlow = { source: { name: 'a', namespace: 'shop', kind: 'Pod' }, destination: { name: 'web', namespace: 'shop', kind: 'Pod' }, protocol: 'tcp', port: 80, lastSeen: '', flowCount: 1, bytesSent: 0, bytesRecv: 0, connections: 1 }
|
|
251
|
+
|
|
252
|
+
it('drops percentiles once two edges with latency are merged', () => {
|
|
253
|
+
const into = { ...base, avgLatencyMs: 10, latencySamples: 90, latencyP50Ms: 9, latencyP95Ms: 30, latencyP99Ms: 40 }
|
|
254
|
+
mergeFlowVolume(into, { ...base, avgLatencyMs: 100, latencySamples: 10, latencyP50Ms: 90, latencyP95Ms: 300, latencyP99Ms: 400 })
|
|
255
|
+
expect(into.latencyP50Ms).toBeUndefined()
|
|
256
|
+
expect(into.latencyP95Ms).toBeUndefined()
|
|
257
|
+
expect(into.avgLatencyMs).toBeCloseTo(19, 6)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('keeps the percentiles of the only edge that had latency', () => {
|
|
261
|
+
const into = { ...base }
|
|
262
|
+
mergeFlowVolume(into, { ...base, avgLatencyMs: 10, latencySamples: 5, latencyP95Ms: 30 })
|
|
263
|
+
expect(into.latencyP95Ms).toBe(30)
|
|
264
|
+
mergeFlowVolume(into, { ...base })
|
|
265
|
+
expect(into.latencyP95Ms).toBe(30)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('weights a response-level edge by its samples', () => {
|
|
269
|
+
expect(latencyWeightOf({ ...base, avgLatencyMs: 5, latencySamples: 42 })).toBe(42)
|
|
270
|
+
expect(latencyWeightOf({ ...base, avgLatencyMs: 5, requestRate: 3, latencySamples: 42 })).toBe(3)
|
|
271
|
+
expect(latencyWeightOf({ ...base })).toBe(0)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('coverageLabel', () => {
|
|
276
|
+
it('says how far back the flows reach from when they were collected', () => {
|
|
277
|
+
expect(coverageLabel('2026-09-30T08:37:24Z', '2026-09-30T08:39:04Z')).toBe('last 1m 40s')
|
|
278
|
+
expect(coverageLabel('2026-09-30T08:38:59Z', '2026-09-30T08:39:04Z')).toBe('last 5s')
|
|
279
|
+
})
|
|
280
|
+
it('is empty when the window is fully covered', () => {
|
|
281
|
+
expect(coverageLabel(undefined, '2026-09-30T08:39:04Z')).toBeNull()
|
|
282
|
+
})
|
|
283
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AggregatedFlow, TrafficFlow } from '../../types'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Does a flow fall into one of the selected HTTP status ranges?
|
|
3
5
|
*
|
|
@@ -51,6 +53,115 @@ export function isRateBasedSource(source: string | undefined): boolean {
|
|
|
51
53
|
return source === 'istio' || source === 'beyla'
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The request and 5xx rates of an edge from a rate-based source, unrounded.
|
|
58
|
+
*
|
|
59
|
+
* requestCount / errorCount carry the same figures rounded with a floor of one,
|
|
60
|
+
* which keeps a trickle visible but makes their ratio meaningless at low rates:
|
|
61
|
+
* 0.3 req/s with 0.01 err/s rounds to one of each, a 100% error rate. The counts
|
|
62
|
+
* are only read when the rates are absent — Radar Hub renders clusters whose
|
|
63
|
+
* Radar predates requestRate / errorRate, and for those they are all there is.
|
|
64
|
+
*/
|
|
65
|
+
export function requestRateOf(flow: Pick<AggregatedFlow, 'requestRate' | 'requestCount'>): number {
|
|
66
|
+
return flow.requestRate ?? flow.requestCount ?? 0
|
|
67
|
+
}
|
|
68
|
+
export function errorRateOf(flow: Pick<AggregatedFlow, 'errorRate' | 'errorCount'>): number {
|
|
69
|
+
return flow.errorRate ?? flow.errorCount ?? 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The volume shown for an edge. For a rate-based source `connections` is its
|
|
74
|
+
* request rate rounded with a floor of one — the graph sizes, sorts and filters
|
|
75
|
+
* on that integer — so what is printed is the unrounded rate where the edge has
|
|
76
|
+
* one. An edge with no HTTP rate (plain TCP) keeps its connection figure.
|
|
77
|
+
*/
|
|
78
|
+
export function displayVolume(flow: Pick<AggregatedFlow, 'requestRate' | 'connections'>, isRateBased: boolean): number {
|
|
79
|
+
return isRateBased && flow.requestRate ? flow.requestRate : flow.connections
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A merged edge records the weight behind its average latency on itself, so a
|
|
84
|
+
* later `{...flow}` copy — the Internet collapse, addon grouping — carries it.
|
|
85
|
+
* Client-only: the server never sends it.
|
|
86
|
+
*/
|
|
87
|
+
type LatencyWeighted = AggregatedFlow & { latencyWeight?: number }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How much traffic an edge's average latency stands for: its request rate, or
|
|
91
|
+
* one for an edge with no rate, summed through merges. Zero when it has none.
|
|
92
|
+
* Used to average latencies so a slow trickle does not stand for a busy path.
|
|
93
|
+
*/
|
|
94
|
+
export function latencyWeightOf(flow: AggregatedFlow): number {
|
|
95
|
+
const merged = (flow as LatencyWeighted).latencyWeight
|
|
96
|
+
if (merged !== undefined) return merged
|
|
97
|
+
return flow.avgLatencyMs ? (flow.requestRate || flow.latencySamples || 1) : 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fold one aggregated flow's volume into another's, for the client-side merges
|
|
102
|
+
* that collapse several edges into one. Every figure displayVolume or the error
|
|
103
|
+
* rate reads has to be summed here: a field left out keeps only the first
|
|
104
|
+
* merged edge's value.
|
|
105
|
+
*/
|
|
106
|
+
export function mergeFlowVolume(into: AggregatedFlow, flow: AggregatedFlow): void {
|
|
107
|
+
// A metric-based source reports only an average latency per edge, so the
|
|
108
|
+
// merged edge's is theirs weighted by request rate. The weight behind a merged
|
|
109
|
+
// average is carried apart from the edge's request rate, which also counts
|
|
110
|
+
// traffic that had no latency measured: weighting by that would make the
|
|
111
|
+
// result depend on the order the edges arrived in.
|
|
112
|
+
const wInto = latencyWeightOf(into)
|
|
113
|
+
const wFlow = latencyWeightOf(flow)
|
|
114
|
+
if (wFlow > 0) {
|
|
115
|
+
if (wInto > 0) {
|
|
116
|
+
into.avgLatencyMs = (into.avgLatencyMs! * wInto + flow.avgLatencyMs! * wFlow) / (wInto + wFlow)
|
|
117
|
+
// Percentiles of two edges cannot be combined into a percentile of both,
|
|
118
|
+
// so a merged edge that had latency on both sides keeps only the average.
|
|
119
|
+
delete into.latencyP50Ms
|
|
120
|
+
delete into.latencyP95Ms
|
|
121
|
+
delete into.latencyP99Ms
|
|
122
|
+
} else {
|
|
123
|
+
into.avgLatencyMs = flow.avgLatencyMs
|
|
124
|
+
into.latencyP50Ms = flow.latencyP50Ms
|
|
125
|
+
into.latencyP95Ms = flow.latencyP95Ms
|
|
126
|
+
into.latencyP99Ms = flow.latencyP99Ms
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const weighted: LatencyWeighted = into
|
|
130
|
+
weighted.latencyWeight = wInto + wFlow
|
|
131
|
+
into.connections += flow.connections
|
|
132
|
+
into.bytesSent += flow.bytesSent
|
|
133
|
+
into.bytesRecv += flow.bytesRecv
|
|
134
|
+
into.flowCount += flow.flowCount
|
|
135
|
+
if (flow.requestCount) into.requestCount = (into.requestCount || 0) + flow.requestCount
|
|
136
|
+
if (flow.errorCount) into.errorCount = (into.errorCount || 0) + flow.errorCount
|
|
137
|
+
if (flow.requestRate) into.requestRate = (into.requestRate || 0) + flow.requestRate
|
|
138
|
+
if (flow.errorRate) into.errorRate = (into.errorRate || 0) + flow.errorRate
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* How much of the requested window the flows cover, as a short label: "last
|
|
143
|
+
* 1m 40s". Null when the whole window is covered. Measured back from when the
|
|
144
|
+
* data was collected, not from now.
|
|
145
|
+
*/
|
|
146
|
+
export function coverageLabel(coveredSince: string | undefined, collectedAt: string | undefined): string | null {
|
|
147
|
+
if (!coveredSince || !collectedAt) return null
|
|
148
|
+
const seconds = Math.max(0, Math.round((Date.parse(collectedAt) - Date.parse(coveredSince)) / 1000))
|
|
149
|
+
if (!Number.isFinite(seconds)) return null
|
|
150
|
+
const m = Math.floor(seconds / 60)
|
|
151
|
+
const s = seconds % 60
|
|
152
|
+
return `last ${m > 0 ? `${m}m ${s}s` : `${s}s`}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A per-second rate, precise enough to tell a trickle from nothing: 0.30, 12, 1.2K. */
|
|
156
|
+
export function formatRate(rate: number): string {
|
|
157
|
+
if (rate >= 1000) return `${(rate / 1000).toFixed(1)}K`
|
|
158
|
+
if (rate >= 10) return rate.toFixed(0)
|
|
159
|
+
if (rate >= 1) return rate.toFixed(1)
|
|
160
|
+
if (rate >= 0.01) return rate.toFixed(2)
|
|
161
|
+
if (rate > 0) return '<0.01'
|
|
162
|
+
return '0'
|
|
163
|
+
}
|
|
164
|
+
|
|
54
165
|
/**
|
|
55
166
|
* Narrow a set of chosen filter values to the ones still offered.
|
|
56
167
|
*
|
|
@@ -146,3 +257,33 @@ export function isPolicyDropReason(dropReasonDesc: string | undefined, deniedByC
|
|
|
146
257
|
const code = (dropReasonDesc ?? '').toUpperCase()
|
|
147
258
|
return code === 'POLICY_DENIED' || code === 'POLICY_DENY'
|
|
148
259
|
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Drop each HTTP REQUEST record that has a matching RESPONSE: the response
|
|
263
|
+
* carries the status and latency, so it stands for the call. A request with no
|
|
264
|
+
* response stays, because a missing response is worth seeing.
|
|
265
|
+
*
|
|
266
|
+
* `callerOriented` is the server saying its responses run caller → callee on
|
|
267
|
+
* the server's port, like their requests, so they match on endpoints, route and
|
|
268
|
+
* port exactly. Without it the server is a Radar that predates that — Radar Hub
|
|
269
|
+
* renders clusters running such builds — whose responses run server → client on
|
|
270
|
+
* the client's ephemeral port, so they match reversed and without the port.
|
|
271
|
+
* The orientation is taken from the server, never inferred from which records
|
|
272
|
+
* happen to be in the window.
|
|
273
|
+
*/
|
|
274
|
+
export function dedupeHTTPPairs(flows: TrafficFlow[], callerOriented: boolean): TrafficFlow[] {
|
|
275
|
+
const isHTTP = (f: TrafficFlow, type: string) => f.l7Protocol === 'HTTP' && f.l7Type === type
|
|
276
|
+
const call = (from: string, to: string, f: TrafficFlow) =>
|
|
277
|
+
callerOriented
|
|
278
|
+
? `${from}|${to}|${f.httpMethod}|${f.httpPath}|${f.port}`
|
|
279
|
+
: `${from}|${to}|${f.httpMethod}|${f.httpPath}`
|
|
280
|
+
|
|
281
|
+
const answered = new Set<string>()
|
|
282
|
+
for (const f of flows) {
|
|
283
|
+
if (!isHTTP(f, 'RESPONSE')) continue
|
|
284
|
+
answered.add(callerOriented
|
|
285
|
+
? call(f.source.name, f.destination.name, f)
|
|
286
|
+
: call(f.destination.name, f.source.name, f))
|
|
287
|
+
}
|
|
288
|
+
return flows.filter(f => !(isHTTP(f, 'REQUEST') && answered.has(call(f.source.name, f.destination.name, f))))
|
|
289
|
+
}
|
|
@@ -543,7 +543,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
543
543
|
>
|
|
544
544
|
<SearchPillInput
|
|
545
545
|
className={hero
|
|
546
|
-
? `min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-[color,background-color,border-color,box-shadow] duration-[140ms] ${TW_EASE_UI} focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]`
|
|
546
|
+
? `min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-[color,background-color,border-color,box-shadow] duration-[140ms] ${TW_EASE_UI} focus-within:border-[var(--color-brand-500,var(--accent))] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500,var(--accent))_15%,transparent)]`
|
|
547
547
|
: `min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-[color,background-color,border-color] duration-[140ms] ${TW_EASE_UI}`}
|
|
548
548
|
inputClassName={hero ? 'text-lg py-4' : undefined}
|
|
549
549
|
text={text}
|