@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.
Files changed (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +247 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +223 -0
  17. package/src/api/radarFeatures.test.ts +142 -0
  18. package/src/api/radarFeatures.ts +148 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +44 -6
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. package/src/index.ts +1 -0
@@ -9,7 +9,7 @@ describe('currency options', () => {
9
9
  it('offers Auto followed by named ISO 4217 currencies', () => {
10
10
  expect(CURRENCY_OPTIONS[0]).toEqual({
11
11
  value: '',
12
- label: 'Auto (detect from OpenCost/Kubecost)',
12
+ label: 'Automatic (from the cost source)',
13
13
  })
14
14
  expect(CURRENCY_OPTIONS).toContainEqual({
15
15
  value: 'EUR',
@@ -19,7 +19,7 @@ const currencyCodes = [
19
19
  ]
20
20
 
21
21
  export const CURRENCY_OPTIONS: SelectMenuOption[] = [
22
- { value: '', label: 'Auto (detect from OpenCost/Kubecost)' },
22
+ { value: '', label: 'Automatic (from the cost source)' },
23
23
  ...currencyCodes
24
24
  .filter((code) => code !== 'XTS' && code !== 'XXX')
25
25
  .map((code) => {
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  costSourceApplyLabel,
4
4
  prometheusHeadersFromRows,
5
- shouldOfferCostReview,
5
+ pendingSections,
6
6
  shouldShowSettingsFooter,
7
7
  } from "./settings-state";
8
8
 
@@ -23,35 +23,42 @@ describe("Prometheus header edits", () => {
23
23
  });
24
24
  });
25
25
 
26
- describe("Cost settings state", () => {
27
- it("keeps source drafts inline while the Cost section is open", () => {
28
- expect(shouldOfferCostReview(true, "cost")).toBe(false);
26
+ describe("Integration settings state", () => {
27
+ it.each(['prometheus', 'cost', 'argocd'] as const)("offers discard for a %s draft", (section) => {
28
+ expect(pendingSections({ prometheus: false, cost: false, argocd: false, ai: false, [section]: true })).toEqual([section]);
29
29
  expect(
30
30
  shouldShowSettingsFooter({
31
31
  canEditConfig: true,
32
32
  confirmingClose: false,
33
33
  configDirty: false,
34
- costIntegrationDirty: true,
35
- section: "cost",
34
+ integrationDirty: true,
35
+ aiDirtyElsewhere: false,
36
36
  hasSaveMessage: false,
37
37
  }),
38
- ).toBe(false);
38
+ ).toBe(true);
39
39
  });
40
40
 
41
41
  it("offers review from other sections and retains the close guard", () => {
42
- expect(shouldOfferCostReview(true, "overview")).toBe(true);
42
+ expect(pendingSections({ prometheus: true, cost: true, argocd: true, ai: true })).toEqual(['prometheus', 'cost', 'argocd', 'ai']);
43
43
  expect(
44
44
  shouldShowSettingsFooter({
45
45
  canEditConfig: true,
46
46
  confirmingClose: true,
47
47
  configDirty: false,
48
- costIntegrationDirty: true,
49
- section: "cost",
48
+ integrationDirty: true,
49
+ aiDirtyElsewhere: false,
50
50
  hasSaveMessage: false,
51
51
  }),
52
52
  ).toBe(true);
53
53
  });
54
54
 
55
+ it("shows an AI draft to users without owner access, but not their startup or integration drafts", () => {
56
+ const base = { canEditConfig: false, confirmingClose: false, configDirty: true, integrationDirty: true, aiDirtyElsewhere: false, hasSaveMessage: true };
57
+ expect(shouldShowSettingsFooter(base)).toBe(false);
58
+ expect(shouldShowSettingsFooter({ ...base, aiDirtyElsewhere: true })).toBe(true);
59
+ expect(shouldShowSettingsFooter({ ...base, confirmingClose: true })).toBe(true);
60
+ });
61
+
55
62
  it("only claims to test sources that the backend probes", () => {
56
63
  expect(costSourceApplyLabel("auto")).toBe("Test & apply source");
57
64
  expect(costSourceApplyLabel("kubecost")).toBe("Test & apply source");
@@ -9,27 +9,35 @@ export type SettingsSectionId =
9
9
  | 'advanced'
10
10
  | 'privacy'
11
11
 
12
- export function shouldOfferCostReview(
13
- costIntegrationDirty: boolean,
14
- section: SettingsSectionId,
15
- ): boolean {
16
- return costIntegrationDirty && section !== 'cost'
12
+ export type IntegrationSectionId = 'prometheus' | 'cost' | 'argocd'
13
+ // Tabs whose drafts save through their own form, not the startup footer.
14
+ export type PendingSectionId = IntegrationSectionId | 'ai'
15
+
16
+ export const pendingSectionLabels: Record<PendingSectionId, string> = {
17
+ prometheus: 'Metrics', cost: 'Cost', argocd: 'Argo CD', ai: 'AI investigations',
18
+ }
19
+
20
+ export function pendingSections(dirty: Record<PendingSectionId, boolean>): PendingSectionId[] {
21
+ return (['prometheus', 'cost', 'argocd', 'ai'] as const).filter(section => dirty[section])
17
22
  }
18
23
 
24
+ // AI preferences are open to every user, so an AI draft pending in another tab
25
+ // shows the footer even without owner access.
19
26
  export function shouldShowSettingsFooter(input: {
20
27
  canEditConfig: boolean
21
28
  confirmingClose: boolean
22
29
  configDirty: boolean
23
- costIntegrationDirty: boolean
24
- section: SettingsSectionId
30
+ integrationDirty: boolean
31
+ aiDirtyElsewhere: boolean
25
32
  hasSaveMessage: boolean
26
33
  }): boolean {
27
34
  return (
28
- input.canEditConfig &&
29
- (input.confirmingClose ||
30
- input.configDirty ||
31
- shouldOfferCostReview(input.costIntegrationDirty, input.section) ||
32
- input.hasSaveMessage)
35
+ input.confirmingClose ||
36
+ input.aiDirtyElsewhere ||
37
+ (input.canEditConfig &&
38
+ (input.configDirty ||
39
+ input.integrationDirty ||
40
+ input.hasSaveMessage))
33
41
  )
34
42
  }
35
43
 
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { applyClientFilters } from '../../api/timelineSource'
3
+ import type { TimelineEvent } from '../../types'
4
+ import { listIncludesManaged } from './TimelineList'
5
+
6
+ function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
7
+ return {
8
+ timestamp: '2024-01-01T00:00:00.000Z',
9
+ source: 'informer',
10
+ kind: 'Deployment',
11
+ namespace: 'ns-a',
12
+ name: over.id,
13
+ eventType: 'update',
14
+ ...over,
15
+ }
16
+ }
17
+
18
+ // Mirrors the host: the source filter runs first, then the UI keeps the selected kinds.
19
+ function listRows(events: TimelineEvent[], kinds: string[]): string[] {
20
+ return applyClientFilters(events, { includeManaged: listIncludesManaged(false, kinds) })
21
+ .filter((e) => kinds.length === 0 || kinds.includes(e.kind))
22
+ .map((e) => e.id)
23
+ }
24
+
25
+ describe('listIncludesManaged', () => {
26
+ it('includes managed rows when app-scoped or when any kind is selected', () => {
27
+ expect(listIncludesManaged(true, [])).toBe(true)
28
+ expect(listIncludesManaged(false, ['Pod'])).toBe(true)
29
+ expect(listIncludesManaged(false, ['Job'])).toBe(true)
30
+ })
31
+
32
+ it('keeps hiding managed rows for the unfiltered list', () => {
33
+ expect(listIncludesManaged(false, [])).toBe(false)
34
+ })
35
+ })
36
+
37
+ describe('timeline list rows', () => {
38
+ const events = [
39
+ ev({ id: 'dep', kind: 'Deployment' }),
40
+ ev({ id: 'pod', kind: 'Pod' }),
41
+ ev({ id: 'job', kind: 'Job' }),
42
+ ev({ id: 'cron-job', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' } }),
43
+ ]
44
+
45
+ it('lists pod rows for a Pod selection', () => {
46
+ expect(listRows(events, ['Pod'])).toEqual(['pod'])
47
+ })
48
+
49
+ it('lists owned rows of an unmanaged kind for that kind', () => {
50
+ expect(listRows(events, ['Job']).sort()).toEqual(['cron-job', 'job'])
51
+ })
52
+
53
+ it('hides pod and owned rows when no kind is selected', () => {
54
+ expect(listRows(events, []).sort()).toEqual(['dep', 'job'])
55
+ })
56
+ })
@@ -24,6 +24,12 @@ export type { ActivityTypeFilter, ActivityFilterKey }
24
24
  const LIST_FETCH_LIMIT = 2000
25
25
  const APP_SCOPED_FETCH_LIMIT = 10000
26
26
 
27
+ // The source drops managed rows (Pod, ReplicaSet, Event, or any owned row) before
28
+ // the kind filter runs, so an explicit kind selection must keep them or it lists nothing.
29
+ export function listIncludesManaged(appScoped: boolean, kinds: string[]): boolean {
30
+ return appScoped || kinds.length > 0
31
+ }
32
+
27
33
  interface TimelineListProps {
28
34
  namespaces: string[]
29
35
  onViewChange?: (view: 'list' | 'swimlane') => void
@@ -76,7 +82,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
76
82
  kinds: queryParams.kinds,
77
83
  timeRange: queryParams.timeRange,
78
84
  includeK8sEvents: true,
79
- includeManaged: appScoped,
85
+ includeManaged: listIncludesManaged(appScoped, queryParams.kinds),
80
86
  includeDeleted: showDeleted,
81
87
  limit: fetchLimit,
82
88
  fromMs: selectionWindow?.fromMs,
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
2
2
  import { Virtuoso } from 'react-virtuoso'
3
3
  import type { TrafficFlow, TrafficEndpoint } from '../../types'
4
4
  import { clsx } from 'clsx'
5
+ import { dedupeHTTPPairs } from './trafficFilters'
5
6
  import { ChevronDown, ChevronUp, ShieldCheck } from 'lucide-react'
6
7
  import { SEVERITY_BADGE, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
7
8
  import { pluralize, StatusDot } from '@skyhook-io/k8s-ui'
@@ -53,9 +54,11 @@ type SortDir = 'asc' | 'desc'
53
54
 
54
55
  interface TrafficFlowListProps {
55
56
  flows: TrafficFlow[]
57
+ /** The server said its L7 responses run caller → callee; see dedupeHTTPPairs. */
58
+ responsesCallerOriented: boolean
56
59
  }
57
60
 
58
- export function TrafficFlowList({ flows }: TrafficFlowListProps) {
61
+ export function TrafficFlowList({ flows, responsesCallerOriented }: TrafficFlowListProps) {
59
62
  const [search] = useFlowSearch()
60
63
  const [sortField, setSortField] = useState<SortField>('time')
61
64
  const [sortDir, setSortDir] = useState<SortDir>('desc')
@@ -70,26 +73,7 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
70
73
  }
71
74
  }
72
75
 
73
- // Deduplicate HTTP REQUEST/RESPONSE pairs: prefer RESPONSE (has status + latency).
74
- // Keep orphan REQUESTs (no matching RESPONSE) as they indicate missing responses.
75
- // Deduplicate HTTP REQUEST/RESPONSE pairs: prefer RESPONSE (has status + latency).
76
- // REQUEST goes client→server, RESPONSE goes server→client (src/dst swapped).
77
- const deduped = useMemo(() => {
78
- // RESPONSE goes server→client, REQUEST goes client→server (src/dst swapped).
79
- // Normalize key: always client|server|method|path
80
- const responseKeys = new Set<string>()
81
- for (const f of flows) {
82
- if (f.l7Protocol === 'HTTP' && f.l7Type === 'RESPONSE') {
83
- responseKeys.add(`${f.destination.name}|${f.source.name}|${f.httpMethod}|${f.httpPath}`)
84
- }
85
- }
86
- return flows.filter(f => {
87
- if (f.l7Protocol === 'HTTP' && f.l7Type === 'REQUEST') {
88
- return !responseKeys.has(`${f.source.name}|${f.destination.name}|${f.httpMethod}|${f.httpPath}`)
89
- }
90
- return true
91
- })
92
- }, [flows])
76
+ const deduped = useMemo(() => dedupeHTTPPairs(flows, responsesCallerOriented), [flows, responsesCallerOriented])
93
77
 
94
78
  const filtered = useMemo(() => {
95
79
  if (!search) return deduped
@@ -4,12 +4,13 @@ import type { TrafficGraphSelection } from './TrafficGraph'
4
4
 
5
5
  interface TrafficFlowListContextValue {
6
6
  flows: TrafficFlow[]
7
+ responsesCallerOriented: boolean
7
8
  graphSelection: TrafficGraphSelection | null
8
9
  clearSelection: () => void
9
10
  }
10
11
 
11
12
  // Module-level store — TrafficView writes, dock tab reads.
12
- let currentValue: TrafficFlowListContextValue = { flows: [], graphSelection: null, clearSelection: () => {} }
13
+ let currentValue: TrafficFlowListContextValue = { flows: [], responsesCallerOriented: false, graphSelection: null, clearSelection: () => {} }
13
14
  const listeners = new Set<() => void>()
14
15
 
15
16
  function setValue(val: TrafficFlowListContextValue) {
@@ -49,17 +50,18 @@ export function useFlowSearch(): [string, (val: string) => void] {
49
50
  // Provider — call this from TrafficView to publish flow data
50
51
  export function TrafficFlowListProvider({
51
52
  flows,
53
+ responsesCallerOriented,
52
54
  graphSelection,
53
55
  clearSelection,
54
56
  children,
55
57
  }: TrafficFlowListContextValue & { children: ReactNode }) {
56
58
  useEffect(() => {
57
- setValue({ flows, graphSelection, clearSelection })
58
- }, [flows, graphSelection, clearSelection])
59
+ setValue({ flows, responsesCallerOriented, graphSelection, clearSelection })
60
+ }, [flows, responsesCallerOriented, graphSelection, clearSelection])
59
61
 
60
62
  useEffect(() => {
61
63
  return () => {
62
- setValue({ flows: [], graphSelection: null, clearSelection: () => {} })
64
+ setValue({ flows: [], responsesCallerOriented: false, graphSelection: null, clearSelection: () => {} })
63
65
  setFlowSearch('')
64
66
  }
65
67
  }, [])
@@ -23,7 +23,7 @@ import { isClusterAddon, type AddonMode } from './TrafficView'
23
23
  import { SEVERITY_BADGE, SEVERITY_DOT, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
24
24
  import { getNamespaceColor } from '../../utils/traffic-colors'
25
25
  import { Tooltip } from '../ui/Tooltip'
26
- import { isRateBasedSource, isExternalKind } from './trafficFilters'
26
+ import { isRateBasedSource, isExternalKind, requestRateOf, errorRateOf, formatRate, displayVolume, latencyWeightOf } from './trafficFilters'
27
27
 
28
28
  const elk = new ELK()
29
29
 
@@ -74,6 +74,12 @@ function formatConnections(count: number): string {
74
74
  return count.toString()
75
75
  }
76
76
 
77
+ // A volume in the source's own unit: a rate to two significant places for a
78
+ // rate-based source, a whole count otherwise.
79
+ function formatVolume(volume: number, isRateBased: boolean): string {
80
+ return isRateBased ? formatRate(volume) : formatConnections(volume)
81
+ }
82
+
77
83
  function formatLatency(ms: number): string {
78
84
  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`
79
85
  if (ms >= 1) return `${ms.toFixed(1)}ms`
@@ -293,7 +299,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
293
299
  ? 'text-white/60'
294
300
  : 'text-theme-text-tertiary'
295
301
  )}>
296
- {formatConnections(portInfo.connections)}{data.connLabel === 'req/s' ? '/s' : ''}
302
+ {formatVolume(portInfo.connections, data.connLabel === 'req/s')}{data.connLabel === 'req/s' ? '/s' : ''}
297
303
  </span>
298
304
  )}
299
305
  </div>
@@ -319,7 +325,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
319
325
  ? 'text-white/70'
320
326
  : 'text-theme-text-tertiary'
321
327
  )}>
322
- {formatConnections(data.totalConnections)} {data.connLabel || 'conn'}
328
+ {formatVolume(data.totalConnections, data.connLabel === 'req/s')} {data.connLabel || 'conn'}
323
329
  </span>
324
330
  </div>
325
331
  )}
@@ -460,7 +466,7 @@ function DetailsPanel({
460
466
  totalBytes: relatedFlows.reduce((sum, f) => sum + f.bytesSent + f.bytesRecv, 0),
461
467
  protocols: relatedFlows.reduce((acc, f) => {
462
468
  const proto = f.protocol?.toUpperCase() || 'TCP'
463
- acc[proto] = (acc[proto] || 0) + f.connections
469
+ acc[proto] = (acc[proto] || 0) + displayVolume(f, isRateBased)
464
470
  return acc
465
471
  }, {} as Record<string, number>),
466
472
  lastSeen: relatedFlows.reduce((latest, f) => {
@@ -468,22 +474,28 @@ function DetailsPanel({
468
474
  return !latest || f.lastSeen > latest ? f.lastSeen : latest
469
475
  }, null as string | null),
470
476
  flowCount: relatedFlows.reduce((sum, f) => sum + (f.flowCount || 1), 0),
471
- totalRequests: relatedFlows.reduce((sum, f) => sum + (f.requestCount ?? 0), 0),
472
- totalErrors: relatedFlows.reduce((sum, f) => sum + (f.errorCount ?? 0), 0),
477
+ totalRequests: relatedFlows.reduce((sum, f) => sum + requestRateOf(f), 0),
478
+ totalErrors: relatedFlows.reduce((sum, f) => sum + errorRateOf(f), 0),
473
479
  l7Protocols: relatedFlows.reduce((acc, f) => {
474
480
  if (f.l7Protocol) acc.add(f.l7Protocol)
475
481
  return acc
476
482
  }, new Set<string>()),
477
- // Aggregate latency across edges (median of P50s, max of P95s)
478
- latencyP50Ms: (() => {
479
- const p50s = relatedFlows.map(f => f.latencyP50Ms).filter((v): v is number => v != null && v > 0)
480
- if (p50s.length === 0) return undefined
481
- p50s.sort((a, b) => a - b)
482
- return p50s[Math.floor(p50s.length / 2)]
483
+ // Percentiles of separate edges do not combine into a node percentile, so
484
+ // the node shows the slowest edge's P95 as that edge's, named.
485
+ slowestP95: (() => {
486
+ let slowest: AggregatedFlow | undefined
487
+ for (const f of relatedFlows) {
488
+ if (f.latencyP95Ms && (!slowest || f.latencyP95Ms > slowest.latencyP95Ms!)) slowest = f
489
+ }
490
+ return slowest && { ms: slowest.latencyP95Ms!, edge: `${slowest.source.workload || slowest.source.name} → ${slowest.destination.workload || slowest.destination.name}` }
483
491
  })(),
484
- latencyP95Ms: (() => {
485
- const p95s = relatedFlows.map(f => f.latencyP95Ms).filter((v): v is number => v != null && v > 0)
486
- return p95s.length > 0 ? Math.max(...p95s) : undefined
492
+ // A metric-based source reports only an average per edge. The node's is
493
+ // those averages weighted by the traffic each stands for, so a slow trickle
494
+ // does not stand for a node that answers almost everything quickly.
495
+ avgLatencyMs: (() => {
496
+ const total = relatedFlows.reduce((sum, f) => sum + latencyWeightOf(f), 0)
497
+ if (total === 0) return undefined
498
+ return relatedFlows.reduce((sum, f) => sum + (f.avgLatencyMs ?? 0) * latencyWeightOf(f), 0) / total
487
499
  })(),
488
500
  // Aggregate HTTP status distribution
489
501
  httpStatusCounts: relatedFlows.reduce((acc, f) => {
@@ -572,7 +584,7 @@ function DetailsPanel({
572
584
  {nodeData.totalConnections != null && nodeData.totalConnections > 0 && (
573
585
  <div className="text-xs text-theme-text-secondary">
574
586
  {isRateBased ? 'Total request rate' : 'Total connections'}: <span className="text-theme-text-primary font-medium">
575
- {formatConnections(nodeData.totalConnections)}{isRateBased ? '/s' : ''}
587
+ {formatVolume(nodeData.totalConnections, isRateBased)}{isRateBased ? '/s' : ''}
576
588
  </span>
577
589
  </div>
578
590
  )}
@@ -596,14 +608,14 @@ function DetailsPanel({
596
608
  {nodeStats.totalRequests > 0 && (
597
609
  <div className="p-2 rounded bg-theme-elevated text-xs">
598
610
  <div className="text-theme-text-tertiary">Requests</div>
599
- <div className="text-theme-text-primary font-medium">{formatConnections(nodeStats.totalRequests)}/s</div>
611
+ <div className="text-theme-text-primary font-medium">{formatRate(nodeStats.totalRequests)}/s</div>
600
612
  </div>
601
613
  )}
602
614
  {nodeStats.totalErrors > 0 && (
603
615
  <div className="p-2 rounded bg-red-500/10 border border-red-500/30 text-xs">
604
616
  <div className="text-red-400">Errors (5xx)</div>
605
617
  <div className="text-red-400 font-medium">
606
- {formatConnections(nodeStats.totalErrors)}/s
618
+ {formatRate(nodeStats.totalErrors)}/s
607
619
  {nodeStats.totalRequests > 0 && (
608
620
  <span className="text-red-300 ml-1">
609
621
  ({((nodeStats.totalErrors / nodeStats.totalRequests) * 100).toFixed(1)}%)
@@ -626,7 +638,7 @@ function DetailsPanel({
626
638
  .map(([proto, count]) => (
627
639
  <span key={proto} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-theme-bg text-theme-text-secondary">
628
640
  <span className="font-medium">{proto}</span>
629
- <span className="text-theme-text-tertiary">{formatConnections(count)}{isRateBased ? '/s' : ''}</span>
641
+ <span className="text-theme-text-tertiary">{formatVolume(count, isRateBased)}{isRateBased ? '/s' : ''}</span>
630
642
  </span>
631
643
  ))}
632
644
  </div>
@@ -636,16 +648,17 @@ function DetailsPanel({
636
648
  )}
637
649
 
638
650
  {/* Node latency */}
639
- {nodeStats?.latencyP50Ms && (
651
+ {nodeStats?.avgLatencyMs && (
640
652
  <div className="pt-1">
641
- <div className="text-[10px] text-theme-text-tertiary mb-1">Latency</div>
642
- <div className="flex gap-2 text-xs">
643
- <span className={clsx('font-medium', latencyColor(nodeStats.latencyP50Ms))}>
644
- P50: {formatLatency(nodeStats.latencyP50Ms)}
653
+ <div className="text-[10px] text-theme-text-tertiary mb-1">Latency across its edges</div>
654
+ <div className="flex flex-col gap-0.5 text-xs">
655
+ <span className={clsx('font-medium', latencyColor(nodeStats.avgLatencyMs))}>
656
+ Avg: {formatLatency(nodeStats.avgLatencyMs)}
645
657
  </span>
646
- {nodeStats.latencyP95Ms && (
647
- <span className={clsx('font-medium', latencyColor(nodeStats.latencyP95Ms))}>
648
- P95: {formatLatency(nodeStats.latencyP95Ms)}
658
+ {nodeStats.slowestP95 && (
659
+ <span className="text-theme-text-secondary min-w-0 truncate">
660
+ Slowest edge P95: <span className={clsx('font-medium', latencyColor(nodeStats.slowestP95.ms))}>{formatLatency(nodeStats.slowestP95.ms)}</span>
661
+ <span className="text-theme-text-tertiary"> ({nodeStats.slowestP95.edge})</span>
649
662
  </span>
650
663
  )}
651
664
  </div>
@@ -712,7 +725,7 @@ function DetailsPanel({
712
725
  </span>
713
726
  {flow.connections > 0 && (
714
727
  <span className="text-theme-text-secondary">
715
- {formatConnections(flow.connections)} {isRateBased ? 'req/s' : 'conn'}
728
+ {formatVolume(displayVolume(flow, isRateBased), isRateBased)} {isRateBased ? 'req/s' : 'conn'}
716
729
  </span>
717
730
  )}
718
731
  {(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
@@ -722,7 +735,7 @@ function DetailsPanel({
722
735
  )}
723
736
  {flow.errorCount != null && flow.errorCount > 0 && (
724
737
  <span className="text-red-400">
725
- {formatConnections(flow.errorCount)} err{isRateBased ? '/s' : ''}
738
+ {isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount)} err`}
726
739
  </span>
727
740
  )}
728
741
  </div>
@@ -764,7 +777,7 @@ function DetailsPanel({
764
777
  </span>
765
778
  {flow.connections > 0 && (
766
779
  <span className="text-theme-text-secondary">
767
- {formatConnections(flow.connections)} {isRateBased ? 'req/s' : 'conn'}
780
+ {formatVolume(displayVolume(flow, isRateBased), isRateBased)} {isRateBased ? 'req/s' : 'conn'}
768
781
  </span>
769
782
  )}
770
783
  {(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
@@ -774,7 +787,7 @@ function DetailsPanel({
774
787
  )}
775
788
  {flow.errorCount != null && flow.errorCount > 0 && (
776
789
  <span className="text-red-400">
777
- {formatConnections(flow.errorCount)} err{isRateBased ? '/s' : ''}
790
+ {isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount)} err`}
778
791
  </span>
779
792
  )}
780
793
  </div>
@@ -827,7 +840,7 @@ function DetailsPanel({
827
840
  <div className="p-2 rounded bg-theme-elevated">
828
841
  <div className="text-theme-text-tertiary">{isRateBased ? 'Request Rate' : 'Connections'}</div>
829
842
  <div className="text-theme-text-primary font-medium">
830
- {formatConnections(edgeData.connections)}{isRateBased ? '/s' : ''}
843
+ {formatVolume(edgeData.flow ? displayVolume(edgeData.flow, isRateBased) : edgeData.connections, isRateBased)}{isRateBased ? '/s' : ''}
831
844
  </div>
832
845
  </div>
833
846
  )}
@@ -839,22 +852,22 @@ function DetailsPanel({
839
852
  </div>
840
853
  </div>
841
854
  )}
842
- {edgeData.flow?.requestCount != null && edgeData.flow.requestCount > 0 && (
855
+ {edgeData.flow && requestRateOf(edgeData.flow) > 0 && (
843
856
  <div className="p-2 rounded bg-theme-elevated">
844
857
  <div className="text-theme-text-tertiary">Requests</div>
845
858
  <div className="text-theme-text-primary font-medium">
846
- {formatConnections(edgeData.flow.requestCount)}/s
859
+ {formatRate(requestRateOf(edgeData.flow))}/s
847
860
  </div>
848
861
  </div>
849
862
  )}
850
- {edgeData.flow?.errorCount != null && edgeData.flow.errorCount > 0 && (
863
+ {edgeData.flow && errorRateOf(edgeData.flow) > 0 && (
851
864
  <div className="p-2 rounded bg-red-500/10 border border-red-500/30">
852
865
  <div className="text-red-400">Errors (5xx)</div>
853
866
  <div className="text-red-400 font-medium">
854
- {formatConnections(edgeData.flow.errorCount)}/s
855
- {edgeData.flow.requestCount != null && edgeData.flow.requestCount > 0 && (
867
+ {formatRate(errorRateOf(edgeData.flow))}/s
868
+ {requestRateOf(edgeData.flow) > 0 && (
856
869
  <span className="text-red-300 ml-1">
857
- ({((edgeData.flow.errorCount / edgeData.flow.requestCount) * 100).toFixed(1)}%)
870
+ ({((errorRateOf(edgeData.flow) / requestRateOf(edgeData.flow)) * 100).toFixed(1)}%)
858
871
  </span>
859
872
  )}
860
873
  </div>
@@ -881,6 +894,21 @@ function DetailsPanel({
881
894
  ))}
882
895
  </div>
883
896
  </div>
897
+ ) : edgeData.flow?.avgLatencyMs ? (
898
+ // The source measured an average, not a distribution: there is
899
+ // no P95 to show, and repeating the average under that label
900
+ // would present it as one.
901
+ <div className="pt-2 border-t border-theme-border">
902
+ <div className="text-[10px] text-theme-text-tertiary mb-1.5">Latency</div>
903
+ <div className="grid grid-cols-3 gap-1.5 text-xs">
904
+ <div className="p-1.5 rounded bg-theme-elevated text-center">
905
+ <div className="text-theme-text-tertiary text-[9px]">Avg</div>
906
+ <div className={clsx('font-medium', latencyColor(edgeData.flow.avgLatencyMs))}>
907
+ {formatLatency(edgeData.flow.avgLatencyMs)}
908
+ </div>
909
+ </div>
910
+ </div>
911
+ </div>
884
912
  ) : null}
885
913
 
886
914
  {/* HTTP status distribution */}
@@ -1115,13 +1143,15 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1115
1143
  connectionCounts.set(destId, (connectionCounts.get(destId) || 0) + 1)
1116
1144
 
1117
1145
  // Sum total connections
1118
- totalConnections.set(sourceId, (totalConnections.get(sourceId) || 0) + flow.connections)
1119
- totalConnections.set(destId, (totalConnections.get(destId) || 0) + flow.connections)
1146
+ // Display-only sums: the unrounded rate for a rate-based source.
1147
+ const volume = displayVolume(flow, isRateBased)
1148
+ totalConnections.set(sourceId, (totalConnections.get(sourceId) || 0) + volume)
1149
+ totalConnections.set(destId, (totalConnections.get(destId) || 0) + volume)
1120
1150
 
1121
1151
  // Track ports for destination nodes
1122
1152
  if (!nodePorts.has(destId)) nodePorts.set(destId, new Map())
1123
1153
  const portCounts = nodePorts.get(destId)!
1124
- portCounts.set(flow.port, (portCounts.get(flow.port) || 0) + flow.connections)
1154
+ portCounts.set(flow.port, (portCounts.get(flow.port) || 0) + volume)
1125
1155
 
1126
1156
  // Track hot path nodes (Phase 2.3)
1127
1157
  if (flow.connections >= hotPathThreshold && hotPathThreshold > 0) {
@@ -1289,14 +1319,15 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1289
1319
 
1290
1320
  // Phase 2.2: Edge label - connection count with unit suffix + L7 details
1291
1321
  const connStr = isRateBased
1292
- ? `${formatConnections(flow.connections)}/s`
1322
+ ? `${formatRate(displayVolume(flow, isRateBased))}/s`
1293
1323
  : formatConnections(flow.connections)
1294
1324
  const l7Label = flow.l7Protocol ? `${flow.l7Protocol} · ` : ''
1295
- const latencyLabel = flow.latencyP50Ms ? ` · ${formatLatency(flow.latencyP50Ms)}` : ''
1325
+ const edgeLatency = flow.latencyP50Ms || flow.avgLatencyMs
1326
+ const latencyLabel = edgeLatency ? ` · ${formatLatency(edgeLatency)}` : ''
1296
1327
  // errorCount is a rate for a rate-based source, exactly like connections above.
1297
1328
  // Without the suffix a destination failing once a second reads as one error.
1298
1329
  const errorLabel = hasErrors
1299
- ? ` · ${formatConnections(flow.errorCount ?? 0)} err${isRateBased ? '/s' : ''}`
1330
+ ? ` · ${isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount ?? 0)} err`}`
1300
1331
  : ''
1301
1332
  // A source that measures rates has no count for an edge with no requests on
1302
1333
  // it — a plain TCP conversation, or one whose direction is unknown. Printing
@@ -1471,7 +1502,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1471
1502
 
1472
1503
  // Add edge from addon-internet to addon-group if we have one
1473
1504
  if (addonMode === 'group' && groupEdgeInfo) {
1474
- const { connections } = groupEdgeInfo
1505
+ const { connections, flow: groupFlow } = groupEdgeInfo
1475
1506
  const sourceId = 'addon-internet'
1476
1507
  const isHotEdge = connections >= hotPathThreshold && hotPathThreshold > 0
1477
1508
 
@@ -1481,7 +1512,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1481
1512
  target: 'addon-group',
1482
1513
  type: 'smoothstep',
1483
1514
  animated: isHotEdge,
1484
- label: isRateBased ? `${formatConnections(connections)}/s` : formatConnections(connections),
1515
+ label: isRateBased ? `${formatRate(displayVolume(groupFlow, isRateBased))}/s` : formatConnections(connections),
1485
1516
  labelBgStyle: {
1486
1517
  fill: '#581c87', // purple-900
1487
1518
  fillOpacity: 0.9,
@@ -1506,7 +1537,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1506
1537
 
1507
1538
  // Add edge from addon-group to kubernetes if we have one
1508
1539
  if (addonMode === 'group' && groupOutEdgeInfo) {
1509
- const { connections, targetId } = groupOutEdgeInfo
1540
+ const { connections, targetId, flow: groupFlow } = groupOutEdgeInfo
1510
1541
  const isHotEdge = connections >= hotPathThreshold && hotPathThreshold > 0
1511
1542
 
1512
1543
  finalEdges.push({
@@ -1515,7 +1546,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1515
1546
  target: targetId,
1516
1547
  type: 'smoothstep',
1517
1548
  animated: isHotEdge,
1518
- label: isRateBased ? `${formatConnections(connections)}/s` : formatConnections(connections),
1549
+ label: isRateBased ? `${formatRate(displayVolume(groupFlow, isRateBased))}/s` : formatConnections(connections),
1519
1550
  labelBgStyle: {
1520
1551
  fill: '#581c87', // purple-900
1521
1552
  fillOpacity: 0.9,