@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
|
@@ -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: '
|
|
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: '
|
|
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
|
-
|
|
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("
|
|
27
|
-
it(
|
|
28
|
-
expect(
|
|
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
|
-
|
|
35
|
-
|
|
34
|
+
integrationDirty: true,
|
|
35
|
+
aiDirtyElsewhere: false,
|
|
36
36
|
hasSaveMessage: false,
|
|
37
37
|
}),
|
|
38
|
-
).toBe(
|
|
38
|
+
).toBe(true);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it("offers review from other sections and retains the close guard", () => {
|
|
42
|
-
expect(
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
30
|
+
integrationDirty: boolean
|
|
31
|
+
aiDirtyElsewhere: boolean
|
|
25
32
|
hasSaveMessage: boolean
|
|
26
33
|
}): boolean {
|
|
27
34
|
return (
|
|
28
|
-
input.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
|
472
|
-
totalErrors: relatedFlows.reduce((sum, f) => sum + (f
|
|
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
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
{
|
|
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">{
|
|
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
|
-
{
|
|
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">{
|
|
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?.
|
|
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-
|
|
643
|
-
<span className={clsx('font-medium', latencyColor(nodeStats.
|
|
644
|
-
|
|
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.
|
|
647
|
-
<span className=
|
|
648
|
-
P95: {formatLatency(nodeStats.
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
-
{
|
|
859
|
+
{formatRate(requestRateOf(edgeData.flow))}/s
|
|
847
860
|
</div>
|
|
848
861
|
</div>
|
|
849
862
|
)}
|
|
850
|
-
{edgeData.flow
|
|
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
|
-
{
|
|
855
|
-
{edgeData.flow
|
|
867
|
+
{formatRate(errorRateOf(edgeData.flow))}/s
|
|
868
|
+
{requestRateOf(edgeData.flow) > 0 && (
|
|
856
869
|
<span className="text-red-300 ml-1">
|
|
857
|
-
({((edgeData.flow
|
|
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
|
-
|
|
1119
|
-
|
|
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) +
|
|
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
|
-
? `${
|
|
1322
|
+
? `${formatRate(displayVolume(flow, isRateBased))}/s`
|
|
1293
1323
|
: formatConnections(flow.connections)
|
|
1294
1324
|
const l7Label = flow.l7Protocol ? `${flow.l7Protocol} · ` : ''
|
|
1295
|
-
const
|
|
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
|
|
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 ? `${
|
|
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 ? `${
|
|
1549
|
+
label: isRateBased ? `${formatRate(displayVolume(groupFlow, isRateBased))}/s` : formatConnections(connections),
|
|
1519
1550
|
labelBgStyle: {
|
|
1520
1551
|
fill: '#581c87', // purple-900
|
|
1521
1552
|
fillOpacity: 0.9,
|