@skyhook-io/radar-app 0.2.1 → 0.3.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 +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/assets/platform-icons/aws_eks.png +0 -0
- package/src/assets/platform-icons/azure-aks.svg +2 -0
- package/src/assets/platform-icons/google_kubernetes_engine.png +0 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +116 -52
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +38 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { ActivityFilterKey, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import {
|
|
4
|
+
parseTimeMode,
|
|
5
|
+
resolveApplicationTimelineScope,
|
|
6
|
+
writeTimelineParams,
|
|
7
|
+
onlyHighFreqDiffer,
|
|
8
|
+
timeModeEqual,
|
|
9
|
+
type TimelineMode,
|
|
10
|
+
type PersistedTimelineState,
|
|
11
|
+
type TimelineViewMode,
|
|
12
|
+
} from './TimelineView'
|
|
13
|
+
|
|
14
|
+
// These mirror the module-private defaults writeTimelineParams omits at (and
|
|
15
|
+
// parseTimeMode falls back to). Kept in sync deliberately: the round-trip tests
|
|
16
|
+
// below fail loudly if a module default drifts from what is asserted here.
|
|
17
|
+
const DEFAULT_LIVE_WIDTH_MS = 60 * 60 * 1000
|
|
18
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
19
|
+
const DEFAULT_VIEW: TimelineViewMode = 'swimlane'
|
|
20
|
+
const DEFAULT_GROUPING: TimelineGrouping = 'app'
|
|
21
|
+
const DEFAULT_SORT: TimelineSort = 'importance'
|
|
22
|
+
const ACTIVITY_KEYS: readonly ActivityFilterKey[] = ['changes', 'k8s_events', 'warnings', 'unhealthy']
|
|
23
|
+
const GROUPINGS: readonly TimelineGrouping[] = ['app', 'owner', 'flat']
|
|
24
|
+
const SORTS: readonly TimelineSort[] = ['importance', 'recent', 'name']
|
|
25
|
+
|
|
26
|
+
const live = (widthMs: number, all?: boolean): TimelineMode => (all ? { kind: 'live', widthMs, all } : { kind: 'live', widthMs })
|
|
27
|
+
const frozen = (fromMs: number, toMs: number): TimelineMode => ({ kind: 'frozen', fromMs, toMs })
|
|
28
|
+
const sp = (obj: Record<string, string>) => new URLSearchParams(obj)
|
|
29
|
+
|
|
30
|
+
// The inverse of writeTimelineParams, reconstructed from the component's own
|
|
31
|
+
// URL->state read (TimelineView's sync effect). Uses the exported parseTimeMode
|
|
32
|
+
// for the time mode; the remaining fields parse inline the same way the
|
|
33
|
+
// component does, so parse(write(state)) exercises the real deep-link contract.
|
|
34
|
+
const parseEnum = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T =>
|
|
35
|
+
value != null && (allowed as readonly string[]).includes(value) ? (value as T) : fallback
|
|
36
|
+
|
|
37
|
+
function parseState(
|
|
38
|
+
params: URLSearchParams,
|
|
39
|
+
opts: { isRetained: boolean; requiresNamespaceFilter: boolean; maxRangeDays?: number; hasPins: boolean },
|
|
40
|
+
): PersistedTimelineState {
|
|
41
|
+
const view = params.get('view')
|
|
42
|
+
const viewMode: TimelineViewMode = opts.requiresNamespaceFilter
|
|
43
|
+
? 'list'
|
|
44
|
+
: view === 'list' || view === 'swimlane'
|
|
45
|
+
? view
|
|
46
|
+
: DEFAULT_VIEW
|
|
47
|
+
const activityRaw = params.get('activity')
|
|
48
|
+
const activityFilter =
|
|
49
|
+
activityRaw == null
|
|
50
|
+
? []
|
|
51
|
+
: activityRaw
|
|
52
|
+
.split(',')
|
|
53
|
+
.map((s) => s.trim())
|
|
54
|
+
.filter((s): s is ActivityFilterKey => (ACTIVITY_KEYS as readonly string[]).includes(s))
|
|
55
|
+
const kindsRaw = params.get('kinds')
|
|
56
|
+
const kindFilter = kindsRaw ? kindsRaw.split(',').map((s) => s.trim()).filter(Boolean) : []
|
|
57
|
+
return {
|
|
58
|
+
viewMode,
|
|
59
|
+
mode: parseTimeMode(params, opts.isRetained, opts.maxRangeDays),
|
|
60
|
+
showDeleted: params.get('deleted') !== '0',
|
|
61
|
+
pinnedOnly: params.get('pinnedOnly') === '1' && opts.hasPins,
|
|
62
|
+
search: params.get('q') ?? '',
|
|
63
|
+
activityFilter,
|
|
64
|
+
kindFilter,
|
|
65
|
+
grouping: parseEnum(params.get('grouping'), GROUPINGS, DEFAULT_GROUPING),
|
|
66
|
+
sort: parseEnum(params.get('sort'), SORTS, DEFAULT_SORT),
|
|
67
|
+
selectedEventId: params.get('event'),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const defaultState: PersistedTimelineState = {
|
|
72
|
+
viewMode: DEFAULT_VIEW,
|
|
73
|
+
mode: live(DEFAULT_LIVE_WIDTH_MS),
|
|
74
|
+
showDeleted: true,
|
|
75
|
+
pinnedOnly: false,
|
|
76
|
+
search: '',
|
|
77
|
+
activityFilter: [],
|
|
78
|
+
kindFilter: [],
|
|
79
|
+
grouping: DEFAULT_GROUPING,
|
|
80
|
+
sort: DEFAULT_SORT,
|
|
81
|
+
selectedEventId: null,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sortedEntries = (params: URLSearchParams) => [...params.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
85
|
+
|
|
86
|
+
describe('parseTimeMode', () => {
|
|
87
|
+
it('round-trips the live-window presets encoded in ?window', () => {
|
|
88
|
+
expect(parseTimeMode(sp({ window: 'all' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS, true))
|
|
89
|
+
expect(parseTimeMode(sp({ window: '1800000' }), true)).toEqual(live(1_800_000))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('parses an explicit from/to into a frozen window', () => {
|
|
93
|
+
const from = 1_700_000_000_000
|
|
94
|
+
const to = from + 2 * DAY_MS
|
|
95
|
+
expect(parseTimeMode(sp({ from: String(from), to: String(to) }), true)).toEqual(frozen(from, to))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('rejects from >= to and falls back to the default live window', () => {
|
|
99
|
+
const t = 1_700_000_000_000
|
|
100
|
+
expect(parseTimeMode(sp({ from: String(t), to: String(t) }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
|
|
101
|
+
expect(parseTimeMode(sp({ from: String(t + 1000), to: String(t) }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('caps an out-of-retention from to maxRangeDays before to', () => {
|
|
105
|
+
const to = 10 * DAY_MS
|
|
106
|
+
expect(parseTimeMode(sp({ from: '1', to: String(to) }), true, 7)).toEqual(frozen(to - 7 * DAY_MS, to))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('leaves from uncapped when already within maxRangeDays', () => {
|
|
110
|
+
const to = 10 * DAY_MS
|
|
111
|
+
const from = to - 3 * DAY_MS
|
|
112
|
+
expect(parseTimeMode(sp({ from: String(from), to: String(to) }), true, 7)).toEqual(frozen(from, to))
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('falls back to the default mode on garbage from/to/window', () => {
|
|
116
|
+
expect(parseTimeMode(sp({ from: 'abc', to: 'def' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
|
|
117
|
+
expect(parseTimeMode(sp({ from: '1.5', to: '9.5' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
|
|
118
|
+
expect(parseTimeMode(sp({ window: 'abc' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
|
|
119
|
+
expect(parseTimeMode(sp({ window: '0' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
|
|
120
|
+
expect(parseTimeMode(sp({ window: '-5' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('ignores from/to/window entirely when the source is not retained', () => {
|
|
124
|
+
const from = 1_700_000_000_000
|
|
125
|
+
expect(
|
|
126
|
+
parseTimeMode(sp({ from: String(from), to: String(from + DAY_MS), window: 'all' }), false),
|
|
127
|
+
).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('writeTimelineParams', () => {
|
|
132
|
+
const retainedOpts = { isRetained: true, requiresNamespaceFilter: false as boolean | undefined }
|
|
133
|
+
|
|
134
|
+
it('writes no params for the pristine default state', () => {
|
|
135
|
+
const written = writeTimelineParams(new URLSearchParams(), defaultState, retainedOpts)
|
|
136
|
+
expect(written.toString()).toBe('')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('writes exactly the expected keys for a fully non-default state', () => {
|
|
140
|
+
const state: PersistedTimelineState = {
|
|
141
|
+
viewMode: 'list',
|
|
142
|
+
mode: live(6 * 60 * 60 * 1000),
|
|
143
|
+
showDeleted: false,
|
|
144
|
+
pinnedOnly: true,
|
|
145
|
+
search: 'nginx',
|
|
146
|
+
activityFilter: ['warnings', 'unhealthy'],
|
|
147
|
+
kindFilter: ['Pod', 'Deployment'],
|
|
148
|
+
grouping: 'owner',
|
|
149
|
+
sort: 'recent',
|
|
150
|
+
selectedEventId: 'evt-42',
|
|
151
|
+
}
|
|
152
|
+
const written = writeTimelineParams(new URLSearchParams(), state, retainedOpts)
|
|
153
|
+
expect(sortedEntries(written)).toEqual([
|
|
154
|
+
['activity', 'warnings,unhealthy'],
|
|
155
|
+
['deleted', '0'],
|
|
156
|
+
['event', 'evt-42'],
|
|
157
|
+
['grouping', 'owner'],
|
|
158
|
+
['kinds', 'Pod,Deployment'],
|
|
159
|
+
['pinnedOnly', '1'],
|
|
160
|
+
['q', 'nginx'],
|
|
161
|
+
['sort', 'recent'],
|
|
162
|
+
['view', 'list'],
|
|
163
|
+
['window', '21600000'],
|
|
164
|
+
])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('encodes a frozen window as from/to and omits window', () => {
|
|
168
|
+
const from = 1_700_000_000_000
|
|
169
|
+
const to = from + 2 * DAY_MS
|
|
170
|
+
const written = writeTimelineParams(new URLSearchParams(), { ...defaultState, mode: frozen(from, to) }, retainedOpts)
|
|
171
|
+
expect(written.get('from')).toBe(String(from))
|
|
172
|
+
expect(written.get('to')).toBe(String(to))
|
|
173
|
+
expect(written.has('window')).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('preserves foreign params, including application scope, and strips the legacy filter seed', () => {
|
|
177
|
+
const base = new URLSearchParams({
|
|
178
|
+
tab: 'topology',
|
|
179
|
+
app: 'staging/Deployment/api',
|
|
180
|
+
scopeNamespaces: 'argocd,staging',
|
|
181
|
+
filter: 'warnings',
|
|
182
|
+
})
|
|
183
|
+
const written = writeTimelineParams(base, defaultState, retainedOpts)
|
|
184
|
+
expect(written.get('tab')).toBe('topology')
|
|
185
|
+
expect(written.get('app')).toBe('staging/Deployment/api')
|
|
186
|
+
expect(written.get('scopeNamespaces')).toBe('argocd,staging')
|
|
187
|
+
expect(written.has('filter')).toBe(false)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('does not persist the list view a large cluster forces', () => {
|
|
191
|
+
const written = writeTimelineParams(new URLSearchParams(), { ...defaultState, viewMode: 'list' }, {
|
|
192
|
+
isRetained: true,
|
|
193
|
+
requiresNamespaceFilter: true,
|
|
194
|
+
})
|
|
195
|
+
expect(written.has('view')).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('resolveApplicationTimelineScope', () => {
|
|
200
|
+
it('uses the current namespace selection outside application scope', () => {
|
|
201
|
+
expect(resolveApplicationTimelineScope(new URLSearchParams(), ['default', 'staging'])).toEqual({
|
|
202
|
+
appKey: null,
|
|
203
|
+
namespaces: ['default', 'staging'],
|
|
204
|
+
ready: true,
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('uses the application scope namespaces and removes duplicates', () => {
|
|
209
|
+
const params = sp({
|
|
210
|
+
app: '/Application/radar-hub-staging',
|
|
211
|
+
scopeNamespaces: 'argocd, staging,argocd',
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
expect(resolveApplicationTimelineScope(params, ['default'])).toEqual({
|
|
215
|
+
appKey: '/Application/radar-hub-staging',
|
|
216
|
+
namespaces: ['argocd', 'staging'],
|
|
217
|
+
ready: true,
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('fails closed when an application link omits its namespace scope', () => {
|
|
222
|
+
const params = sp({ app: '/Application/radar-hub-staging' })
|
|
223
|
+
|
|
224
|
+
expect(resolveApplicationTimelineScope(params, ['default'])).toEqual({
|
|
225
|
+
appKey: '/Application/radar-hub-staging',
|
|
226
|
+
namespaces: [],
|
|
227
|
+
ready: false,
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('parse(write(state)) round-trip', () => {
|
|
233
|
+
const opts = { isRetained: true, requiresNamespaceFilter: false, maxRangeDays: undefined, hasPins: true }
|
|
234
|
+
const roundTrip = (state: PersistedTimelineState) => {
|
|
235
|
+
const written = writeTimelineParams(new URLSearchParams(), state, {
|
|
236
|
+
isRetained: opts.isRetained,
|
|
237
|
+
requiresNamespaceFilter: opts.requiresNamespaceFilter,
|
|
238
|
+
})
|
|
239
|
+
return parseState(new URLSearchParams(written.toString()), opts)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
it('round-trips the pristine default state', () => {
|
|
243
|
+
expect(roundTrip(defaultState)).toEqual(defaultState)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('round-trips a live preset with every control set', () => {
|
|
247
|
+
const state: PersistedTimelineState = {
|
|
248
|
+
viewMode: 'list',
|
|
249
|
+
mode: live(6 * 60 * 60 * 1000),
|
|
250
|
+
showDeleted: false,
|
|
251
|
+
pinnedOnly: true,
|
|
252
|
+
search: 'nginx',
|
|
253
|
+
activityFilter: ['warnings', 'unhealthy'],
|
|
254
|
+
kindFilter: ['Pod', 'Deployment'],
|
|
255
|
+
grouping: 'owner',
|
|
256
|
+
sort: 'recent',
|
|
257
|
+
selectedEventId: 'evt-42',
|
|
258
|
+
}
|
|
259
|
+
expect(roundTrip(state)).toEqual(state)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('round-trips a frozen window with a selected event', () => {
|
|
263
|
+
const from = 1_700_000_000_000
|
|
264
|
+
const state: PersistedTimelineState = {
|
|
265
|
+
...defaultState,
|
|
266
|
+
mode: frozen(from, from + 2 * DAY_MS),
|
|
267
|
+
selectedEventId: 'evt-frozen',
|
|
268
|
+
}
|
|
269
|
+
expect(roundTrip(state)).toEqual(state)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('round-trips a whole-span live window and a search with a space', () => {
|
|
273
|
+
const state: PersistedTimelineState = {
|
|
274
|
+
...defaultState,
|
|
275
|
+
mode: live(DEFAULT_LIVE_WIDTH_MS, true),
|
|
276
|
+
search: 'foo bar',
|
|
277
|
+
activityFilter: ['changes'],
|
|
278
|
+
sort: 'name',
|
|
279
|
+
}
|
|
280
|
+
expect(roundTrip(state)).toEqual(state)
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('timeModeEqual', () => {
|
|
285
|
+
it('is reflexive for live and frozen modes', () => {
|
|
286
|
+
expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS), live(DEFAULT_LIVE_WIDTH_MS))).toBe(true)
|
|
287
|
+
expect(timeModeEqual(frozen(1, 2), frozen(1, 2))).toBe(true)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('treats the whole-span all flag as significant', () => {
|
|
291
|
+
expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS), live(DEFAULT_LIVE_WIDTH_MS, true))).toBe(false)
|
|
292
|
+
expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS, true), live(DEFAULT_LIVE_WIDTH_MS, true))).toBe(true)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('is false across the live/frozen boundary', () => {
|
|
296
|
+
expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS), frozen(0, DEFAULT_LIVE_WIDTH_MS))).toBe(false)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('distinguishes different widths and different frozen windows', () => {
|
|
300
|
+
expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS), live(2 * DEFAULT_LIVE_WIDTH_MS))).toBe(false)
|
|
301
|
+
expect(timeModeEqual(frozen(1, 2), frozen(1, 3))).toBe(false)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('onlyHighFreqDiffer (history replace vs push)', () => {
|
|
306
|
+
it('replaces when only a high-frequency key differs (window pan/zoom, search, drawer event)', () => {
|
|
307
|
+
expect(onlyHighFreqDiffer('window=3600000', 'window=7200000')).toBe(true)
|
|
308
|
+
expect(onlyHighFreqDiffer('q=a', 'q=b')).toBe(true)
|
|
309
|
+
expect(onlyHighFreqDiffer('event=evt-1', 'event=evt-2')).toBe(true)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('replaces on a live<->frozen transition — every time-window key is high-frequency', () => {
|
|
313
|
+
expect(onlyHighFreqDiffer('window=7200000', 'from=1&to=2')).toBe(true)
|
|
314
|
+
expect(onlyHighFreqDiffer('', 'from=1&to=2')).toBe(true)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('pushes when a discrete control changes', () => {
|
|
318
|
+
expect(onlyHighFreqDiffer('view=list', 'view=swimlane')).toBe(false)
|
|
319
|
+
expect(onlyHighFreqDiffer('', 'grouping=owner')).toBe(false)
|
|
320
|
+
expect(onlyHighFreqDiffer('', 'sort=recent')).toBe(false)
|
|
321
|
+
expect(onlyHighFreqDiffer('', 'activity=warnings')).toBe(false)
|
|
322
|
+
expect(onlyHighFreqDiffer('', 'kinds=Pod')).toBe(false)
|
|
323
|
+
expect(onlyHighFreqDiffer('', 'deleted=0')).toBe(false)
|
|
324
|
+
expect(onlyHighFreqDiffer('', 'pinnedOnly=1')).toBe(false)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('pushes when a diff mixes a high-frequency and a discrete key', () => {
|
|
328
|
+
expect(onlyHighFreqDiffer('', 'window=7200000&grouping=owner')).toBe(false)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('is not replace-worthy when nothing differs', () => {
|
|
332
|
+
expect(onlyHighFreqDiffer('window=3600000', 'window=3600000')).toBe(false)
|
|
333
|
+
expect(onlyHighFreqDiffer('', '')).toBe(false)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { memo, useState
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
1
|
+
import { memo, useState } from 'react'
|
|
3
2
|
import {
|
|
4
3
|
ChevronDown,
|
|
5
4
|
Eye,
|
|
@@ -16,43 +15,8 @@ import { clsx } from 'clsx'
|
|
|
16
15
|
import { SEVERITY_BADGE } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
17
16
|
import type { AddonMode } from './TrafficView'
|
|
18
17
|
import { getNamespaceColor } from '../../utils/traffic-colors'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
|
|
22
|
-
const [show, setShow] = useState(false)
|
|
23
|
-
const [pos, setPos] = useState({ x: 0, y: 0 })
|
|
24
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
25
|
-
|
|
26
|
-
const handleMouseEnter = () => {
|
|
27
|
-
if (ref.current) {
|
|
28
|
-
const rect = ref.current.getBoundingClientRect()
|
|
29
|
-
setPos({ x: rect.right + 8, y: rect.top + rect.height / 2 })
|
|
30
|
-
}
|
|
31
|
-
setShow(true)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<div
|
|
36
|
-
ref={ref}
|
|
37
|
-
className="inline-flex"
|
|
38
|
-
onMouseEnter={handleMouseEnter}
|
|
39
|
-
onMouseLeave={() => setShow(false)}
|
|
40
|
-
>
|
|
41
|
-
{children}
|
|
42
|
-
{show && createPortal(
|
|
43
|
-
<div
|
|
44
|
-
className="fixed z-[9999] pointer-events-none"
|
|
45
|
-
style={{ left: pos.x, top: pos.y, transform: 'translateY(-50%)' }}
|
|
46
|
-
>
|
|
47
|
-
<div className="bg-gray-900 text-white text-[10px] px-2 py-1.5 rounded shadow-lg max-w-[180px] leading-tight whitespace-normal">
|
|
48
|
-
{content}
|
|
49
|
-
</div>
|
|
50
|
-
</div>,
|
|
51
|
-
document.body
|
|
52
|
-
)}
|
|
53
|
-
</div>
|
|
54
|
-
)
|
|
55
|
-
}
|
|
18
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
19
|
+
import { Input } from '@skyhook-io/k8s-ui'
|
|
56
20
|
|
|
57
21
|
// Connection threshold options
|
|
58
22
|
const CONNECTION_THRESHOLDS = [
|
|
@@ -152,7 +116,7 @@ function ToggleOption({
|
|
|
152
116
|
{label}
|
|
153
117
|
</span>
|
|
154
118
|
</button>
|
|
155
|
-
<Tooltip content={description}>
|
|
119
|
+
<Tooltip content={description} position="right">
|
|
156
120
|
<Info className="w-3 h-3 text-theme-text-tertiary hover:text-theme-text-secondary cursor-help" />
|
|
157
121
|
</Tooltip>
|
|
158
122
|
<button
|
|
@@ -225,29 +189,31 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
225
189
|
<div className="px-3 py-2 border-b border-theme-border space-y-1.5">
|
|
226
190
|
<div className="flex items-center gap-2">
|
|
227
191
|
<Clock className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
192
|
+
<Tooltip content="Show traffic from the selected time window" wrapperClassName="!flex flex-1">
|
|
228
193
|
<select
|
|
229
194
|
value={timeRange}
|
|
230
195
|
onChange={(e) => setTimeRange(e.target.value)}
|
|
231
|
-
title="Show traffic from the selected time window"
|
|
232
196
|
className="flex-1 bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
233
197
|
>
|
|
234
198
|
{TIME_RANGES.map(({ value, label }) => (
|
|
235
199
|
<option key={value} value={value}>{label}</option>
|
|
236
200
|
))}
|
|
237
201
|
</select>
|
|
202
|
+
</Tooltip>
|
|
238
203
|
</div>
|
|
239
204
|
<div className="flex items-center gap-2">
|
|
240
205
|
<Filter className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
206
|
+
<Tooltip content="Hide low-traffic flows to reduce noise" wrapperClassName="!flex flex-1">
|
|
241
207
|
<select
|
|
242
208
|
value={minConnections}
|
|
243
209
|
onChange={(e) => setMinConnections(Number(e.target.value))}
|
|
244
|
-
title="Hide low-traffic flows to reduce noise"
|
|
245
210
|
className="flex-1 bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
246
211
|
>
|
|
247
212
|
{CONNECTION_THRESHOLDS.map(({ value, label }) => (
|
|
248
213
|
<option key={value} value={value}>{label}</option>
|
|
249
214
|
))}
|
|
250
215
|
</select>
|
|
216
|
+
</Tooltip>
|
|
251
217
|
</div>
|
|
252
218
|
</div>
|
|
253
219
|
|
|
@@ -279,7 +245,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
279
245
|
<div className="flex items-center gap-2 mb-1.5">
|
|
280
246
|
<Puzzle className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
281
247
|
<span className="text-xs text-theme-text-primary">Cluster Addons</span>
|
|
282
|
-
<Tooltip content="Monitoring, logging, cert-manager, etc. Excludes ingress controllers and service mesh.">
|
|
248
|
+
<Tooltip content="Monitoring, logging, cert-manager, etc. Excludes ingress controllers and service mesh." position="right">
|
|
283
249
|
<Info className="w-3 h-3 text-theme-text-tertiary hover:text-theme-text-secondary cursor-help" />
|
|
284
250
|
</Tooltip>
|
|
285
251
|
</div>
|
|
@@ -433,8 +399,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
433
399
|
{(l7Protocol === 'all' || l7Protocol === 'DNS') && (
|
|
434
400
|
<div>
|
|
435
401
|
<div className="text-[10px] text-theme-text-tertiary mb-1">DNS Query</div>
|
|
436
|
-
<
|
|
437
|
-
type="text"
|
|
402
|
+
<Input
|
|
438
403
|
value={dnsPattern}
|
|
439
404
|
onChange={(e) => setDnsPattern(e.target.value)}
|
|
440
405
|
placeholder="e.g. example.com"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
|
+
import { Virtuoso } from 'react-virtuoso'
|
|
2
3
|
import type { TrafficFlow } from '../../types'
|
|
3
4
|
import { clsx } from 'clsx'
|
|
4
5
|
import { ChevronDown, ChevronUp, ShieldCheck } from 'lucide-react'
|
|
@@ -7,6 +8,7 @@ import { pluralize } from '@skyhook-io/k8s-ui'
|
|
|
7
8
|
import { useFlowSearch } from './TrafficFlowListContext'
|
|
8
9
|
import { useQuery } from '@tanstack/react-query'
|
|
9
10
|
import { fetchJSON } from '../../api/client'
|
|
11
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
10
12
|
|
|
11
13
|
// DNS response code names
|
|
12
14
|
const DNS_RCODES: Record<number, string> = {
|
|
@@ -141,14 +143,18 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
|
|
|
141
143
|
<span className="text-right">Verdict</span>
|
|
142
144
|
</div>
|
|
143
145
|
|
|
144
|
-
{/* Flow rows
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
{/* Flow rows — virtualized so tens of thousands of Hubble/Cilium flows
|
|
147
|
+
don't all become DOM. Virtuoso measures variable row heights, so the
|
|
148
|
+
expand/collapse panel still works. */}
|
|
149
|
+
{sorted.length === 0 ? (
|
|
150
|
+
<div className="flex-1 flex items-center justify-center text-sm text-theme-text-tertiary">
|
|
151
|
+
{search ? 'No flows match the search' : 'No flows to display'}
|
|
152
|
+
</div>
|
|
153
|
+
) : (
|
|
154
|
+
<Virtuoso
|
|
155
|
+
className="flex-1"
|
|
156
|
+
data={sorted}
|
|
157
|
+
itemContent={(i, flow) => {
|
|
152
158
|
const isExpanded = expandedIdx === i
|
|
153
159
|
const isHTTP = flow.l7Protocol === 'HTTP'
|
|
154
160
|
const isDNS = flow.l7Protocol === 'DNS'
|
|
@@ -169,28 +175,36 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
|
|
|
169
175
|
<span className="text-theme-text-tertiary tabular-nums whitespace-nowrap">{time}</span>
|
|
170
176
|
|
|
171
177
|
{/* Source */}
|
|
172
|
-
<
|
|
178
|
+
<Tooltip content={flow.source.namespace ? `${flow.source.namespace}/${flow.source.name}` : flow.source.name} wrapperClassName="min-w-0">
|
|
179
|
+
<span className="truncate text-theme-text-primary">
|
|
173
180
|
{flow.source.name}
|
|
174
181
|
</span>
|
|
182
|
+
</Tooltip>
|
|
175
183
|
|
|
176
184
|
{/* Destination */}
|
|
177
|
-
<
|
|
185
|
+
<Tooltip content={flow.destination.namespace ? `${flow.destination.namespace}/${flow.destination.name}` : flow.destination.name} wrapperClassName="min-w-0">
|
|
186
|
+
<span className="truncate text-theme-text-primary">
|
|
178
187
|
{flow.destination.name}
|
|
179
188
|
</span>
|
|
189
|
+
</Tooltip>
|
|
180
190
|
|
|
181
191
|
{/* Request info */}
|
|
182
192
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
183
193
|
{isHTTP && (
|
|
184
194
|
<>
|
|
185
195
|
<span className={clsx('shrink-0 badge badge-sm text-[10px]', SEVERITY_BADGE.info)}>{flow.httpMethod}</span>
|
|
186
|
-
<
|
|
196
|
+
<Tooltip content={flow.httpPath ?? ''} wrapperClassName="min-w-0">
|
|
197
|
+
<span className="truncate text-theme-text-secondary">{flow.httpPath}</span>
|
|
198
|
+
</Tooltip>
|
|
187
199
|
{flow.l7Type === 'REQUEST' && <span className={clsx('shrink-0 text-[9px]', SEVERITY_TEXT.warning)}>no response</span>}
|
|
188
200
|
</>
|
|
189
201
|
)}
|
|
190
202
|
{isDNS && (
|
|
191
203
|
<>
|
|
192
204
|
<span className={clsx('shrink-0 badge badge-sm text-[10px]', SEVERITY_BADGE.neutral)}>DNS</span>
|
|
193
|
-
<
|
|
205
|
+
<Tooltip content={flow.dnsQuery ?? ''} wrapperClassName="min-w-0">
|
|
206
|
+
<span className="truncate text-theme-text-secondary">{flow.dnsQuery}</span>
|
|
207
|
+
</Tooltip>
|
|
194
208
|
</>
|
|
195
209
|
)}
|
|
196
210
|
{!isHTTP && !isDNS && (
|
|
@@ -316,9 +330,9 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
|
|
|
316
330
|
)}
|
|
317
331
|
</div>
|
|
318
332
|
)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
333
|
+
}}
|
|
334
|
+
/>
|
|
335
|
+
)}
|
|
322
336
|
|
|
323
337
|
{/* Footer */}
|
|
324
338
|
<div className="px-3 py-1.5 border-t border-theme-border text-[10px] text-theme-text-tertiary">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useEffect, useState, useCallback, useRef } from 'react'
|
|
1
|
+
import { useMemo, useEffect, useState, useCallback, useRef, type MutableRefObject } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
ReactFlow,
|
|
4
4
|
Background,
|
|
@@ -22,6 +22,7 @@ import { X, ArrowRight, Globe, Server, Activity, Puzzle } from 'lucide-react'
|
|
|
22
22
|
import { isClusterAddon, type AddonMode } from './TrafficView'
|
|
23
23
|
import { SEVERITY_BADGE, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
24
24
|
import { getNamespaceColor } from '../../utils/traffic-colors'
|
|
25
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
25
26
|
|
|
26
27
|
const elk = new ELK()
|
|
27
28
|
|
|
@@ -845,7 +846,9 @@ function DetailsPanel({
|
|
|
845
846
|
{edgeData.flow.topHTTPPaths.map((p, i) => (
|
|
846
847
|
<div key={i} className="flex items-center gap-1.5 text-[10px]">
|
|
847
848
|
<span className={clsx('shrink-0 px-1 py-0.5 rounded badge font-medium', SEVERITY_BADGE.info)}>{p.method}</span>
|
|
848
|
-
<
|
|
849
|
+
<Tooltip content={p.path} wrapperClassName="min-w-0 flex-1">
|
|
850
|
+
<span className="text-theme-text-primary truncate flex-1">{p.path || '/'}</span>
|
|
851
|
+
</Tooltip>
|
|
849
852
|
<span className="shrink-0 text-theme-text-secondary">{p.count}</span>
|
|
850
853
|
{p.avgMs ? <span className="shrink-0 text-theme-text-tertiary">{formatLatency(p.avgMs)}</span> : null}
|
|
851
854
|
{p.errorPct ? <span className={clsx('shrink-0', p.errorPct > 10 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)}>{p.errorPct.toFixed(0)}%err</span> : null}
|
|
@@ -862,7 +865,9 @@ function DetailsPanel({
|
|
|
862
865
|
<div className="space-y-1 max-h-40 overflow-y-auto">
|
|
863
866
|
{edgeData.flow.topDNSQueries.map((q, i) => (
|
|
864
867
|
<div key={i} className="flex items-center gap-1.5 text-[10px]">
|
|
865
|
-
<
|
|
868
|
+
<Tooltip content={q.query} wrapperClassName="min-w-0 flex-1">
|
|
869
|
+
<span className="text-theme-text-primary truncate flex-1">{q.query}</span>
|
|
870
|
+
</Tooltip>
|
|
866
871
|
<span className="shrink-0 text-theme-text-secondary">{q.count}</span>
|
|
867
872
|
{q.nxCount ? <span className={clsx('shrink-0', SEVERITY_TEXT.warning)}>NX:{q.nxCount}</span> : null}
|
|
868
873
|
{q.avgTTL ? <span className="shrink-0 text-theme-text-tertiary">TTL:{q.avgTTL}s</span> : null}
|
|
@@ -966,6 +971,33 @@ const nodeTypes = {
|
|
|
966
971
|
addonGroup: AddonGroupNode,
|
|
967
972
|
}
|
|
968
973
|
|
|
974
|
+
// Fits the view once nodes have been laid out. Module-scope (not defined inside
|
|
975
|
+
// TrafficGraph's render) so it keeps a stable identity — otherwise it remounts
|
|
976
|
+
// every render and its effect churns. Must render inside <ReactFlow> for the
|
|
977
|
+
// useReactFlow() context; the trigger state is passed in as props.
|
|
978
|
+
function FitViewOnChange({
|
|
979
|
+
shouldFitViewRef,
|
|
980
|
+
layoutedNodes,
|
|
981
|
+
}: {
|
|
982
|
+
shouldFitViewRef: MutableRefObject<boolean>
|
|
983
|
+
layoutedNodes: Node<TrafficNodeData>[]
|
|
984
|
+
}) {
|
|
985
|
+
const { fitView } = useReactFlow()
|
|
986
|
+
|
|
987
|
+
useEffect(() => {
|
|
988
|
+
if (shouldFitViewRef.current && layoutedNodes.length > 0) {
|
|
989
|
+
// Small delay to ensure nodes are rendered
|
|
990
|
+
const timer = setTimeout(() => {
|
|
991
|
+
fitView({ padding: 0.2, duration: 200 })
|
|
992
|
+
shouldFitViewRef.current = false
|
|
993
|
+
}, 50)
|
|
994
|
+
return () => clearTimeout(timer)
|
|
995
|
+
}
|
|
996
|
+
}, [fitView, layoutedNodes, shouldFitViewRef])
|
|
997
|
+
|
|
998
|
+
return null
|
|
999
|
+
}
|
|
1000
|
+
|
|
969
1001
|
export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups = false, serviceCategories, addonMode = 'show', trafficSource = '', onSelectionChange }: TrafficGraphProps) {
|
|
970
1002
|
const isIstio = trafficSource === 'istio'
|
|
971
1003
|
const connLabel = isIstio ? 'req/s' : 'conn'
|
|
@@ -1264,9 +1296,13 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1264
1296
|
try {
|
|
1265
1297
|
const layoutResult = await elk.layout(elkGraph)
|
|
1266
1298
|
|
|
1299
|
+
// Index ELK's positioned children by id once — a .find() per node here is
|
|
1300
|
+
// O(nodes²) and bites on dense traffic graphs.
|
|
1301
|
+
const elkPositions = new Map((layoutResult.children ?? []).map(n => [n.id, n]))
|
|
1302
|
+
|
|
1267
1303
|
// Apply positions from ELK to nodes
|
|
1268
1304
|
let positionedNodes = rawNodes.map(node => {
|
|
1269
|
-
const elkNode =
|
|
1305
|
+
const elkNode = elkPositions.get(node.id)
|
|
1270
1306
|
return {
|
|
1271
1307
|
...node,
|
|
1272
1308
|
position: {
|
|
@@ -1339,7 +1375,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1339
1375
|
}
|
|
1340
1376
|
|
|
1341
1377
|
// Build final edges list
|
|
1342
|
-
|
|
1378
|
+
const finalEdges = [...rawEdges]
|
|
1343
1379
|
|
|
1344
1380
|
// Add edge from addon-internet to addon-group if we have one
|
|
1345
1381
|
if (addonMode === 'group' && groupEdgeInfo) {
|
|
@@ -1483,24 +1519,6 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1483
1519
|
onSelectionChange?.(null)
|
|
1484
1520
|
}, [onSelectionChange])
|
|
1485
1521
|
|
|
1486
|
-
// FitView handler component - must be inside ReactFlow
|
|
1487
|
-
const FitViewOnChange = () => {
|
|
1488
|
-
const { fitView } = useReactFlow()
|
|
1489
|
-
|
|
1490
|
-
useEffect(() => {
|
|
1491
|
-
if (shouldFitViewRef.current && layoutedNodes.length > 0) {
|
|
1492
|
-
// Small delay to ensure nodes are rendered
|
|
1493
|
-
const timer = setTimeout(() => {
|
|
1494
|
-
fitView({ padding: 0.2, duration: 200 })
|
|
1495
|
-
shouldFitViewRef.current = false
|
|
1496
|
-
}, 50)
|
|
1497
|
-
return () => clearTimeout(timer)
|
|
1498
|
-
}
|
|
1499
|
-
}, [fitView, layoutedNodes])
|
|
1500
|
-
|
|
1501
|
-
return null
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
1522
|
return (
|
|
1505
1523
|
<div className="w-full h-full relative">
|
|
1506
1524
|
<ReactFlow
|
|
@@ -1526,7 +1544,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1526
1544
|
>
|
|
1527
1545
|
<Background />
|
|
1528
1546
|
<Controls />
|
|
1529
|
-
<FitViewOnChange />
|
|
1547
|
+
<FitViewOnChange shouldFitViewRef={shouldFitViewRef} layoutedNodes={layoutedNodes} />
|
|
1530
1548
|
</ReactFlow>
|
|
1531
1549
|
|
|
1532
1550
|
{/* Legend */}
|