@skyhook-io/radar-app 0.2.2 → 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/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 +56 -42
- 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 +32 -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
|
@@ -1,8 +1,30 @@
|
|
|
1
|
-
import { useState, useMemo, useRef } from 'react'
|
|
2
|
-
import {
|
|
1
|
+
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
|
2
|
+
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { Network, AlertTriangle, RefreshCw, Boxes, X } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
clampLensToSelection,
|
|
7
|
+
deriveLiveSelection,
|
|
8
|
+
isLensLatched,
|
|
9
|
+
advanceLatchedLens,
|
|
10
|
+
buildAppMembershipIndex,
|
|
11
|
+
eventsForApplication,
|
|
12
|
+
isPinnedLaneRef,
|
|
13
|
+
LIVE_TICK_MS,
|
|
14
|
+
SEVERITY_TEXT,
|
|
15
|
+
type ScrubberRange,
|
|
16
|
+
type TimelineLiveState,
|
|
17
|
+
type TimelineGrouping,
|
|
18
|
+
type TimelineSort,
|
|
19
|
+
type PinnedLaneRef,
|
|
20
|
+
} from '@skyhook-io/k8s-ui'
|
|
3
21
|
import { TimelineList } from './TimelineList'
|
|
22
|
+
import type { ActivityFilterKey } from './TimelineList'
|
|
4
23
|
import { TimelineSwimlanes } from './TimelineSwimlanes'
|
|
5
|
-
import {
|
|
24
|
+
import { RetainedTimelineScrubber, extendSelection, type ScrubberDomainInfo } from './RetainedTimelineScrubber'
|
|
25
|
+
import { LocalTimelineScrubber } from './LocalTimelineScrubber'
|
|
26
|
+
import { useTopology, useApplications } from '../../api/client'
|
|
27
|
+
import { useTimelineSource } from '../../context/TimelineSource'
|
|
6
28
|
import type { Topology } from '../../types'
|
|
7
29
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
8
30
|
import { LargeClusterNamespacePicker } from '../shared/LargeClusterNamespacePicker'
|
|
@@ -10,6 +32,23 @@ import { LargeClusterNamespacePicker } from '../shared/LargeClusterNamespacePick
|
|
|
10
32
|
// Stable empty array to avoid creating new references on every render
|
|
11
33
|
const EMPTY_EVENTS: never[] = []
|
|
12
34
|
|
|
35
|
+
// Pinned-lane persistence (survives refresh + view toggle). A pin record stores
|
|
36
|
+
// enough to render the label with zero event data — either a resource ref or an
|
|
37
|
+
// app-group ref. isPinnedLaneRef tolerates legacy entries (resource refs written
|
|
38
|
+
// before app-group pins existed carry no `type` discriminant).
|
|
39
|
+
const PINNED_LANES_KEY = 'radar.timeline.pinnedLanes'
|
|
40
|
+
function loadPinnedLanes(): PinnedLaneRef[] {
|
|
41
|
+
try {
|
|
42
|
+
const raw = localStorage.getItem(PINNED_LANES_KEY)
|
|
43
|
+
if (!raw) return []
|
|
44
|
+
const parsed = JSON.parse(raw)
|
|
45
|
+
if (!Array.isArray(parsed)) return []
|
|
46
|
+
return parsed.filter(isPinnedLaneRef)
|
|
47
|
+
} catch {
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
13
52
|
// Helper to check if topology has meaningfully changed
|
|
14
53
|
function topologyContentEqual(a: Topology | undefined, b: Topology | undefined): boolean {
|
|
15
54
|
if (a === b) return true
|
|
@@ -27,6 +66,190 @@ import type { TimeRange } from '../../types'
|
|
|
27
66
|
export type TimelineViewMode = 'list' | 'swimlane'
|
|
28
67
|
export type { ActivityTypeFilter } from './TimelineList'
|
|
29
68
|
|
|
69
|
+
// Retained-mode selection model: a relative live window, or a pinned absolute one.
|
|
70
|
+
// Exported (with the URL helpers below) as a test seam for the deep-link contract.
|
|
71
|
+
export type TimelineMode =
|
|
72
|
+
// `all` marks a live window meant to cover the WHOLE data span: its width is
|
|
73
|
+
// re-derived from the scrubber domain each tick, so a growing local ring
|
|
74
|
+
// (toMs = now advances) never slides the left edge off the oldest data the
|
|
75
|
+
// way a fixed widthMs would. widthMs remains the fallback until the domain
|
|
76
|
+
// is known.
|
|
77
|
+
| { kind: 'live'; widthMs: number; all?: boolean }
|
|
78
|
+
| { kind: 'frozen'; fromMs: number; toMs: number }
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// URL persistence for the retained-timeline control surface.
|
|
82
|
+
//
|
|
83
|
+
// Every control TimelineView owns is mirrored into the query string so a
|
|
84
|
+
// timeline is deep-linkable and the browser back/forward buttons restore it.
|
|
85
|
+
// The URL is the source of truth on mount and is rewritten on every user
|
|
86
|
+
// change; each param is OMITTED at its default so a pristine timeline keeps a
|
|
87
|
+
// clean URL. Live mode encodes only a relative window (never absolute times) so
|
|
88
|
+
// a restored live link is still live at restore time; the 30s live tick moves
|
|
89
|
+
// no absolute state and therefore never touches the URL.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Default query: the last hour. Wide-enough for "what just happened" without
|
|
92
|
+
// burying the lanes in a day of history; presets/URL widen it deliberately.
|
|
93
|
+
const DEFAULT_LIVE_WIDTH_MS = 60 * 60 * 1000
|
|
94
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
95
|
+
// Fallback cap for a retained hand-entered ?from&to when the source doesn't
|
|
96
|
+
// declare maxRangeDays — mirrors the retained source's own default.
|
|
97
|
+
const DEFAULT_MAX_RANGE_DAYS = 7
|
|
98
|
+
const DEFAULT_VIEW: TimelineViewMode = 'swimlane'
|
|
99
|
+
const DEFAULT_GROUPING: TimelineGrouping = 'app'
|
|
100
|
+
const DEFAULT_SORT: TimelineSort = 'importance'
|
|
101
|
+
const ACTIVITY_KEYS: readonly ActivityFilterKey[] = ['changes', 'k8s_events', 'warnings', 'unhealthy']
|
|
102
|
+
const GROUPINGS: readonly TimelineGrouping[] = ['app', 'owner', 'flat']
|
|
103
|
+
const SORTS: readonly TimelineSort[] = ['importance', 'recent', 'name']
|
|
104
|
+
// Keys that update at typing / brush-commit frequency — their writes use
|
|
105
|
+
// history replace so they don't flood the back stack; discrete toggles push.
|
|
106
|
+
// `event` (the open drawer's selected id) joins this set: drawer selection is
|
|
107
|
+
// high-frequency (every open/select/close), so its writes replace rather than
|
|
108
|
+
// flood the back stack.
|
|
109
|
+
const HIGH_FREQ_KEYS = new Set(['q', 'from', 'to', 'window', 'event'])
|
|
110
|
+
|
|
111
|
+
export interface PersistedTimelineState {
|
|
112
|
+
viewMode: TimelineViewMode
|
|
113
|
+
mode: TimelineMode
|
|
114
|
+
showDeleted: boolean
|
|
115
|
+
pinnedOnly: boolean
|
|
116
|
+
search: string
|
|
117
|
+
activityFilter: ActivityFilterKey[]
|
|
118
|
+
kindFilter: string[]
|
|
119
|
+
grouping: TimelineGrouping
|
|
120
|
+
sort: TimelineSort
|
|
121
|
+
selectedEventId: string | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseView(sp: URLSearchParams): TimelineViewMode | undefined {
|
|
125
|
+
const v = sp.get('view')
|
|
126
|
+
return v === 'list' || v === 'swimlane' ? v : undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseActivity(sp: URLSearchParams): ActivityFilterKey[] | undefined {
|
|
130
|
+
const raw = sp.get('activity')
|
|
131
|
+
if (raw == null) return undefined
|
|
132
|
+
return raw
|
|
133
|
+
.split(',')
|
|
134
|
+
.map((s) => s.trim())
|
|
135
|
+
.filter((s): s is ActivityFilterKey => (ACTIVITY_KEYS as readonly string[]).includes(s))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseKinds(sp: URLSearchParams): string[] {
|
|
139
|
+
const raw = sp.get('kinds')
|
|
140
|
+
if (!raw) return []
|
|
141
|
+
return raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseEnum<T extends string>(value: string | null, allowed: readonly T[], fallback: T): T {
|
|
145
|
+
return value != null && (allowed as readonly string[]).includes(value) ? (value as T) : fallback
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Absolute [from,to] wins (a frozen link); else a relative live window; else the
|
|
149
|
+
// pristine default live window. Only meaningful in retained mode. `maxRangeDays`
|
|
150
|
+
// caps a hand-entered ?from&to to the same horizon the preset/fetch path
|
|
151
|
+
// enforces (retained only; local loads the whole ring and passes it undefined).
|
|
152
|
+
export function parseTimeMode(sp: URLSearchParams, isRetained: boolean, maxRangeDays?: number): TimelineMode {
|
|
153
|
+
if (isRetained) {
|
|
154
|
+
const from = sp.get('from')
|
|
155
|
+
const to = sp.get('to')
|
|
156
|
+
if (from != null && to != null) {
|
|
157
|
+
const f = Number(from)
|
|
158
|
+
const t = Number(to)
|
|
159
|
+
if (Number.isInteger(f) && Number.isInteger(t) && f > 0 && f < t) {
|
|
160
|
+
const fromMs = maxRangeDays != null ? Math.max(f, t - maxRangeDays * DAY_MS) : f
|
|
161
|
+
return { kind: 'frozen', fromMs, toMs: t }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const w = sp.get('window')
|
|
165
|
+
if (w === 'all') {
|
|
166
|
+
// The width is a fallback until the scrubber domain lands; the flag makes
|
|
167
|
+
// the live selection track the whole span from then on.
|
|
168
|
+
return { kind: 'live', widthMs: DEFAULT_LIVE_WIDTH_MS, all: true }
|
|
169
|
+
}
|
|
170
|
+
if (w != null) {
|
|
171
|
+
const wm = Number(w)
|
|
172
|
+
if (Number.isFinite(wm) && wm > 0) return { kind: 'live', widthMs: Math.round(wm) }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { kind: 'live', widthMs: DEFAULT_LIVE_WIDTH_MS }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function timeModeEqual(a: TimelineMode, b: TimelineMode): boolean {
|
|
179
|
+
if (a.kind === 'live' && b.kind === 'live') return a.widthMs === b.widthMs && (a.all ?? false) === (b.all ?? false)
|
|
180
|
+
if (a.kind === 'frozen' && b.kind === 'frozen') return a.fromMs === b.fromMs && a.toMs === b.toMs
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
185
|
+
return a.length === b.length && a.every((x, i) => x === b[i])
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Rebuild the query string from state, preserving any foreign keys already on
|
|
189
|
+
// the URL and stripping the legacy home-page `filter` seed (superseded by
|
|
190
|
+
// `activity`). Every param is omitted at its default.
|
|
191
|
+
export function writeTimelineParams(
|
|
192
|
+
base: URLSearchParams,
|
|
193
|
+
s: PersistedTimelineState,
|
|
194
|
+
opts: { isRetained: boolean; requiresNamespaceFilter: boolean | undefined },
|
|
195
|
+
): URLSearchParams {
|
|
196
|
+
const p = new URLSearchParams(base)
|
|
197
|
+
const set = (k: string, v: string | null) => (v == null ? p.delete(k) : p.set(k, v))
|
|
198
|
+
|
|
199
|
+
// Never persist the list view forced by a large cluster — only a real choice.
|
|
200
|
+
set('view', !opts.requiresNamespaceFilter && s.viewMode === 'list' ? 'list' : null)
|
|
201
|
+
|
|
202
|
+
// Live is encoded by `window` alone (a relative width, never absolute times, so
|
|
203
|
+
// a restored live link is still live); `from`+`to` encode a frozen range. The
|
|
204
|
+
// `mode` param is deliberately NOT used — App.tsx owns it for the topology view
|
|
205
|
+
// and strips foreign `mode` values.
|
|
206
|
+
if (opts.isRetained && s.mode.kind === 'frozen') {
|
|
207
|
+
set('from', String(s.mode.fromMs))
|
|
208
|
+
set('to', String(s.mode.toMs))
|
|
209
|
+
set('window', null)
|
|
210
|
+
} else if (opts.isRetained && s.mode.kind === 'live' && s.mode.all) {
|
|
211
|
+
set('window', 'all')
|
|
212
|
+
set('from', null)
|
|
213
|
+
set('to', null)
|
|
214
|
+
} else if (opts.isRetained && s.mode.kind === 'live' && s.mode.widthMs !== DEFAULT_LIVE_WIDTH_MS) {
|
|
215
|
+
set('window', String(s.mode.widthMs))
|
|
216
|
+
set('from', null)
|
|
217
|
+
set('to', null)
|
|
218
|
+
} else {
|
|
219
|
+
set('window', null)
|
|
220
|
+
set('from', null)
|
|
221
|
+
set('to', null)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
set('activity', s.activityFilter.length ? s.activityFilter.join(',') : null)
|
|
225
|
+
set('kinds', s.kindFilter.length ? s.kindFilter.join(',') : null)
|
|
226
|
+
set('deleted', s.showDeleted ? null : '0')
|
|
227
|
+
set('pinnedOnly', s.pinnedOnly ? '1' : null)
|
|
228
|
+
set('q', s.search.length ? s.search : null)
|
|
229
|
+
set('grouping', s.grouping !== DEFAULT_GROUPING ? s.grouping : null)
|
|
230
|
+
set('sort', s.sort !== DEFAULT_SORT ? s.sort : null)
|
|
231
|
+
set('event', s.selectedEventId)
|
|
232
|
+
p.delete('filter')
|
|
233
|
+
return p
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A diff is "replace-worthy" when every changed key is high-frequency; a
|
|
237
|
+
// discrete toggle changing pushes a history entry so back/forward step through
|
|
238
|
+
// control states.
|
|
239
|
+
export function onlyHighFreqDiffer(a: string, b: string): boolean {
|
|
240
|
+
const pa = new URLSearchParams(a)
|
|
241
|
+
const pb = new URLSearchParams(b)
|
|
242
|
+
const keys = new Set<string>([...pa.keys(), ...pb.keys()])
|
|
243
|
+
let any = false
|
|
244
|
+
for (const k of keys) {
|
|
245
|
+
if (pa.get(k) !== pb.get(k)) {
|
|
246
|
+
any = true
|
|
247
|
+
if (!HIGH_FREQ_KEYS.has(k)) return false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return any
|
|
251
|
+
}
|
|
252
|
+
|
|
30
253
|
interface TimelineViewProps {
|
|
31
254
|
namespaces: string[]
|
|
32
255
|
onResourceClick?: NavigateToResource
|
|
@@ -38,27 +261,431 @@ interface TimelineViewProps {
|
|
|
38
261
|
onNamespaceSelect?: (ns: string) => void
|
|
39
262
|
}
|
|
40
263
|
|
|
264
|
+
export function resolveApplicationTimelineScope(searchParams: URLSearchParams, namespaces: string[]) {
|
|
265
|
+
const appKey = searchParams.get('app')
|
|
266
|
+
const appNamespaces = Array.from(new Set(
|
|
267
|
+
(searchParams.get('scopeNamespaces') ?? '')
|
|
268
|
+
.split(',')
|
|
269
|
+
.map((namespace) => namespace.trim())
|
|
270
|
+
.filter(Boolean),
|
|
271
|
+
))
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
appKey,
|
|
275
|
+
namespaces: appKey ? appNamespaces : namespaces,
|
|
276
|
+
ready: !appKey || appNamespaces.length > 0,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
41
280
|
export function TimelineView({ namespaces, onResourceClick, initialViewMode, initialFilter, initialTimeRange, requiresNamespaceFilter, availableNamespaces, onNamespaceSelect }: TimelineViewProps) {
|
|
42
|
-
//
|
|
43
|
-
|
|
281
|
+
// URL is the source of truth for every control below (deep-linkable +
|
|
282
|
+
// back/forward-restorable). Read on mount, written on user change.
|
|
283
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
284
|
+
const appScope = useMemo(() => resolveApplicationTimelineScope(searchParams, namespaces), [namespaces, searchParams])
|
|
285
|
+
const focusedAppKey = appScope.appKey
|
|
286
|
+
const appScopeNamespaces = focusedAppKey ? appScope.namespaces : []
|
|
287
|
+
const appScopeReady = appScope.ready
|
|
288
|
+
const timelineNamespaces = appScope.namespaces
|
|
289
|
+
const scopeRequiresNamespaceFilter = Boolean(requiresNamespaceFilter) && appScopeNamespaces.length === 0
|
|
290
|
+
|
|
291
|
+
// Force list view on large clusters without namespace filter; otherwise the
|
|
292
|
+
// URL `view` (or the home-page seed) decides.
|
|
293
|
+
const effectiveInitialMode = scopeRequiresNamespaceFilter ? 'list' : (parseView(searchParams) ?? initialViewMode ?? DEFAULT_VIEW)
|
|
44
294
|
const [viewMode, setViewMode] = useState<TimelineViewMode>(effectiveInitialMode)
|
|
295
|
+
// Shared across list + swimlane so the toggle carries across the view switch,
|
|
296
|
+
// and so the swimlane fetch can exclude deletes server-side (before LIMIT)
|
|
297
|
+
// rather than only hiding them client-side after the 10k cap.
|
|
298
|
+
const [showDeleted, setShowDeleted] = useState(() => searchParams.get('deleted') !== '0')
|
|
299
|
+
// ?pinnedOnly=1 is inert without pins: honoring it with no stored pins would
|
|
300
|
+
// arm a filter that hides everything. Gate the read on stored pins so the param
|
|
301
|
+
// can never arm on its own — ordering-proof, independent of when the empty-pins
|
|
302
|
+
// reset effect runs relative to the URL-sync effect below.
|
|
303
|
+
const [pinnedOnly, setPinnedOnly] = useState(() => searchParams.get('pinnedOnly') === '1' && loadPinnedLanes().length > 0)
|
|
304
|
+
// Search / activity-type / kind lifted here too, so they survive the view
|
|
305
|
+
// switch and drive both views through one source of truth.
|
|
306
|
+
const [search, setSearch] = useState(() => searchParams.get('q') ?? '')
|
|
307
|
+
// Seed the multi-select from the URL `activity` csv, else the home-page
|
|
308
|
+
// deep-link preset: 'all'/undefined means no chips selected (everything).
|
|
309
|
+
const [activityFilter, setActivityFilter] = useState<ActivityFilterKey[]>(
|
|
310
|
+
() => parseActivity(searchParams) ?? (initialFilter && initialFilter !== 'all' ? [initialFilter] : []),
|
|
311
|
+
)
|
|
312
|
+
const [kindFilter, setKindFilter] = useState<string[]>(() => parseKinds(searchParams))
|
|
313
|
+
// Lane grouping mode, lifted here so it survives the list↔swimlane switch like
|
|
314
|
+
// the other view options.
|
|
315
|
+
const [grouping, setGrouping] = useState<TimelineGrouping>(() => parseEnum(searchParams.get('grouping'), GROUPINGS, DEFAULT_GROUPING))
|
|
316
|
+
// Lane sort mode, lifted alongside grouping for the same reason.
|
|
317
|
+
const [sort, setSort] = useState<TimelineSort>(() => parseEnum(searchParams.get('sort'), SORTS, DEFAULT_SORT))
|
|
318
|
+
// The swimlane drawer's open event (routable deep link). The swimlane reports
|
|
319
|
+
// its selection here (open/select/close) and restores from it on mount; a stale
|
|
320
|
+
// id it can't resolve after data settles is stripped back to null.
|
|
321
|
+
const [selectedEventId, setSelectedEventId] = useState<string | null>(() => searchParams.get('event'))
|
|
322
|
+
|
|
323
|
+
// Pinned lanes: stationary rows the user keeps in view while moving the lens.
|
|
324
|
+
// State + localStorage persistence live here (the k8s-ui swimlane is pure and
|
|
325
|
+
// owns no storage), so pins survive the list↔swimlane toggle and a refresh.
|
|
326
|
+
const [pinnedLanes, setPinnedLanes] = useState<PinnedLaneRef[]>(() => loadPinnedLanes())
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
try {
|
|
329
|
+
localStorage.setItem(PINNED_LANES_KEY, JSON.stringify(pinnedLanes))
|
|
330
|
+
} catch {
|
|
331
|
+
// Storage unavailable (private mode / quota) — pins stay in-memory only.
|
|
332
|
+
}
|
|
333
|
+
}, [pinnedLanes])
|
|
334
|
+
const togglePin = useCallback((ref: PinnedLaneRef) => {
|
|
335
|
+
setPinnedLanes((prev) => (
|
|
336
|
+
prev.some((p) => p.id === ref.id) ? prev.filter((p) => p.id !== ref.id) : [...prev, ref]
|
|
337
|
+
))
|
|
338
|
+
}, [])
|
|
339
|
+
// pinnedOnly is meaningless with no pins: unpinning the last lane (or landing
|
|
340
|
+
// on ?pinnedOnly=1 with no stored pins) would otherwise leave the filter stuck
|
|
341
|
+
// on, silently re-hiding everything the moment a lane is pinned again. Drop it
|
|
342
|
+
// whenever pins empty out; the URL-sync effect carries the reset to the URL.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (pinnedLanes.length === 0) {
|
|
345
|
+
setPinnedOnly((prev) => (prev ? false : prev))
|
|
346
|
+
}
|
|
347
|
+
}, [pinnedLanes])
|
|
348
|
+
|
|
349
|
+
// App-group name → the Applications page's deep link (?app=<AppRow.key>),
|
|
350
|
+
// mirroring resource-name navigation.
|
|
351
|
+
const navigate = useNavigate()
|
|
352
|
+
const handleAppClick = useCallback((appKey: string) => {
|
|
353
|
+
navigate(`/applications?app=${encodeURIComponent(appKey)}`)
|
|
354
|
+
}, [navigate])
|
|
45
355
|
|
|
46
356
|
// Only fetch heavy swimlane data when actually showing swimlanes
|
|
47
|
-
const showSwimlanes = viewMode === 'swimlane' && !
|
|
357
|
+
const showSwimlanes = viewMode === 'swimlane' && !scopeRequiresNamespaceFilter && appScopeReady
|
|
358
|
+
|
|
359
|
+
const timelineSource = useTimelineSource()
|
|
360
|
+
const isRetained = timelineSource.capabilities.mode === 'retained'
|
|
361
|
+
const isLocal = timelineSource.capabilities.mode === 'local'
|
|
362
|
+
// Cap for a hand-entered ?from&to, mirroring the retained fetch window's cap.
|
|
363
|
+
// Local mode loads the whole ring and carries no day horizon, so it stays
|
|
364
|
+
// uncapped (undefined).
|
|
365
|
+
const retainedMaxRangeDays = isRetained ? (timelineSource.capabilities.maxRangeDays ?? DEFAULT_MAX_RANGE_DAYS) : undefined
|
|
366
|
+
// The local strip rides both views, matching retained: the ring fetch below
|
|
367
|
+
// is enabled whenever the strip is shown, so list mode has data to bucket.
|
|
368
|
+
// Large clusters that require a namespace filter skip the strip — the same
|
|
369
|
+
// full-ring load the swimlane gate avoids — and the list then shows its own
|
|
370
|
+
// range dropdown instead (selectionWindow is only passed when a scrubber is
|
|
371
|
+
// on screen to own the range).
|
|
372
|
+
const showLocalScrubber = isLocal && !scopeRequiresNamespaceFilter && appScopeReady
|
|
373
|
+
const showScrubber = appScopeReady && (isRetained || showLocalScrubber)
|
|
374
|
+
|
|
375
|
+
// Both sources drive a scrubber now: retained fetches a server overview, local
|
|
376
|
+
// derives one client-side from the loaded ring. The time-selection machinery
|
|
377
|
+
// (mode/selection/lens/live) is therefore active in both; the per-source
|
|
378
|
+
// difference is the fetch window, the gap band (retention-only), and which
|
|
379
|
+
// scrubber component renders.
|
|
380
|
+
// LIVE (relative) — a fixed width pinned to now; slides on a 30s tick.
|
|
381
|
+
// FROZEN (absolute) — an explicit [from,to] pinned by any range action.
|
|
382
|
+
// The concrete selection is DERIVED each render so it drives both the list and
|
|
383
|
+
// swimlane fetches and survives the view toggle.
|
|
384
|
+
const [mode, setMode] = useState<TimelineMode>(() => parseTimeMode(searchParams, isRetained || isLocal, retainedMaxRangeDays))
|
|
385
|
+
// Freeze time surfaced on the paused chip ("as of HH:MM"); stamped when the
|
|
386
|
+
// selection freezes. Null while live.
|
|
387
|
+
const [frozenAsOfMs, setFrozenAsOfMs] = useState<number | null>(null)
|
|
388
|
+
|
|
389
|
+
// 30s clock, ticked only while live. A live selection reads this; frozen mode
|
|
390
|
+
// ignores it and the interval is torn down so nothing auto-updates.
|
|
391
|
+
const [nowTick, setNowTick] = useState(() => Date.now())
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
if (mode.kind !== 'live') return
|
|
394
|
+
const id = setInterval(() => setNowTick(Date.now()), LIVE_TICK_MS)
|
|
395
|
+
return () => clearInterval(id)
|
|
396
|
+
}, [mode.kind])
|
|
397
|
+
|
|
398
|
+
// Server-derived domain + per-request cap, lifted from the scrubber so extend
|
|
399
|
+
// requests clamp to the real retained window (and "all" live widths track it).
|
|
400
|
+
const [scrubberDomain, setScrubberDomain] = useState<ScrubberDomainInfo | null>(null)
|
|
401
|
+
|
|
402
|
+
const selection = useMemo<ScrubberRange>(() => {
|
|
403
|
+
if (mode.kind === 'live') {
|
|
404
|
+
// An "all" live window re-derives its width from the current domain so a
|
|
405
|
+
// growing ring never slides the left edge off the oldest held data.
|
|
406
|
+
const width = mode.all && scrubberDomain ? scrubberDomain.maxSelectionMs : mode.widthMs
|
|
407
|
+
return deriveLiveSelection(width, nowTick)
|
|
408
|
+
}
|
|
409
|
+
return { fromMs: mode.fromMs, toMs: mode.toMs }
|
|
410
|
+
}, [mode, nowTick, scrubberDomain])
|
|
411
|
+
|
|
412
|
+
// The LENS: the swimlane's visible window WITHIN the applied selection. Free
|
|
413
|
+
// client-side exploration — kept in sync with both the scrubber band and the
|
|
414
|
+
// swimlane, and always clamped inside the selection. Default: the most-recent
|
|
415
|
+
// hour of the selection (what the swimlane shows at its default zoom), or the
|
|
416
|
+
// whole selection if that's narrower.
|
|
417
|
+
const DEFAULT_LENS_MS = 60 * 60 * 1000
|
|
418
|
+
const [lensWindow, setLensWindow] = useState<ScrubberRange>(() => {
|
|
419
|
+
const width = Math.min(DEFAULT_LENS_MS, selection.toMs - selection.fromMs)
|
|
420
|
+
return { fromMs: selection.toMs - width, toMs: selection.toMs }
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// Recording gaps lifted from the scrubber so the swimlane renders matching
|
|
424
|
+
// offline bands + empty-state copy.
|
|
425
|
+
const [gaps, setGaps] = useState<ScrubberRange[]>([])
|
|
426
|
+
|
|
427
|
+
// List mode's lens source: the time span of the rows visible in the list's
|
|
428
|
+
// scrollport, reported by the list on scroll. Dragging the strip band works
|
|
429
|
+
// the other way: it sets a scroll target the list jumps to.
|
|
430
|
+
const [listVisibleWindow, setListVisibleWindow] = useState<ScrubberRange | null>(null)
|
|
431
|
+
const [listScrollToMs, setListScrollToMs] = useState<number | undefined>(undefined)
|
|
432
|
+
|
|
433
|
+
// Latest selection for clamping the lens without re-creating the setter.
|
|
434
|
+
const selectionRef = useRef(selection)
|
|
435
|
+
selectionRef.current = selection
|
|
436
|
+
|
|
437
|
+
// Carry the swimlane window into the list ONCE, at the moment of the switch.
|
|
438
|
+
// Deriving the scroll target from the live lensWindow instead would re-scroll
|
|
439
|
+
// the list on every live tick as the latched lens edge advances. Leaving list
|
|
440
|
+
// view drops both the target and the last reported scrollport window — stale
|
|
441
|
+
// values would otherwise flash as the band/lens on the next visit.
|
|
442
|
+
const lensWindowRef = useRef(lensWindow)
|
|
443
|
+
lensWindowRef.current = lensWindow
|
|
444
|
+
const showScrubberRef = useRef(showScrubber)
|
|
445
|
+
showScrubberRef.current = showScrubber
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (viewMode === 'list') {
|
|
448
|
+
setListScrollToMs(showScrubberRef.current ? lensWindowRef.current.toMs : undefined)
|
|
449
|
+
} else {
|
|
450
|
+
setListScrollToMs(undefined)
|
|
451
|
+
setListVisibleWindow(null)
|
|
452
|
+
}
|
|
453
|
+
}, [viewMode])
|
|
48
454
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
const
|
|
52
|
-
|
|
455
|
+
// Single writer for the lens: every update (band drag or swimlane pan/zoom) is
|
|
456
|
+
// clamped inside the current selection so the lens can never leave the query.
|
|
457
|
+
const setLens = useCallback((next: ScrubberRange) => {
|
|
458
|
+
setLensWindow(clampLensToSelection(next, selectionRef.current))
|
|
459
|
+
}, [])
|
|
460
|
+
|
|
461
|
+
// Live slide: on each tick, a lens LATCHED to the live edge advances with the
|
|
462
|
+
// selection (keeping width); one the user dragged into the past stays put and
|
|
463
|
+
// is only re-clamped if the sliding left edge would push it out. Runs only in
|
|
464
|
+
// live mode; frozen mode never ticks.
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
if (mode.kind !== 'live') return
|
|
467
|
+
setLensWindow((prev) => (
|
|
468
|
+
isLensLatched(prev, selection)
|
|
469
|
+
? clampLensToSelection(advanceLatchedLens(prev, selection), selection)
|
|
470
|
+
: clampLensToSelection(prev, selection)
|
|
471
|
+
))
|
|
472
|
+
}, [nowTick, mode.kind, selection])
|
|
473
|
+
|
|
474
|
+
const resetLensToRecent = useCallback((sel: ScrubberRange) => {
|
|
475
|
+
const width = Math.min(DEFAULT_LENS_MS, sel.toMs - sel.fromMs)
|
|
476
|
+
setLensWindow({ fromMs: sel.toMs - width, toMs: sel.toMs })
|
|
477
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
478
|
+
}, [])
|
|
479
|
+
|
|
480
|
+
// Picking a query preset shows the WHOLE new span (band == query): "Last 24h"
|
|
481
|
+
// renders 24h, not just its recent hour. Window-narrowing stays reserved for
|
|
482
|
+
// explicit zoom/drag, which routes through resetLensToRecent.
|
|
483
|
+
const resetLensToFull = useCallback((sel: ScrubberRange) => {
|
|
484
|
+
setLensWindow({ fromMs: sel.fromMs, toMs: sel.toMs })
|
|
485
|
+
}, [])
|
|
486
|
+
|
|
487
|
+
// Any explicit range action from the scrubber (brush Run-query, pan arrows,
|
|
488
|
+
// zoom ±, grab-pan, handle drag, domain clamp) → FROZEN. Nothing auto-updates
|
|
489
|
+
// until a manual refresh. The lens resets to the recent slice of the new range.
|
|
490
|
+
const handleSelectionChange = useCallback((sel: ScrubberRange) => {
|
|
491
|
+
setMode({ kind: 'frozen', fromMs: sel.fromMs, toMs: sel.toMs })
|
|
492
|
+
setFrozenAsOfMs(Date.now())
|
|
493
|
+
resetLensToRecent(sel)
|
|
494
|
+
}, [resetLensToRecent])
|
|
495
|
+
|
|
496
|
+
// A domain clamp (the derived selection outgrew the ring/retained window) is
|
|
497
|
+
// NOT a user range action, so it must preserve the current mode: LIVE stays
|
|
498
|
+
// LIVE, narrowed to the clamped width (a fresh <24h cluster keeps auto-
|
|
499
|
+
// updating instead of freezing on first load); FROZEN stays frozen at the
|
|
500
|
+
// clamped range. Never stamps "as of" — that belongs to real freezes only.
|
|
501
|
+
const handleSelectionClamp = useCallback((sel: ScrubberRange) => {
|
|
502
|
+
setMode((prev) => (
|
|
503
|
+
prev.kind === 'live'
|
|
504
|
+
// `all` survives a clamp: the clamp narrowed the window to what the
|
|
505
|
+
// domain can hold right now, which is exactly what all-mode re-derives
|
|
506
|
+
// next tick anyway.
|
|
507
|
+
? { kind: 'live', widthMs: sel.toMs - sel.fromMs, all: prev.all }
|
|
508
|
+
: { kind: 'frozen', fromMs: sel.fromMs, toMs: sel.toMs }
|
|
509
|
+
))
|
|
510
|
+
resetLensToRecent(sel)
|
|
511
|
+
}, [resetLensToRecent])
|
|
512
|
+
|
|
513
|
+
// Preset click → LIVE with that width (capped to the retained window). Pins to
|
|
514
|
+
// now, starts the tick, and shows the whole new span (window == query).
|
|
515
|
+
const handlePresetSelect = useCallback((widthMs: number) => {
|
|
516
|
+
const capped = scrubberDomain ? Math.min(widthMs, scrubberDomain.maxSelectionMs) : widthMs
|
|
517
|
+
// Only local mode has a domain-tracking maximum ("All" = the whole ring);
|
|
518
|
+
// retained mode's cap is a fixed per-request limit, so its presets stay
|
|
519
|
+
// plain fixed widths.
|
|
520
|
+
const all = isLocal && scrubberDomain != null && widthMs >= scrubberDomain.maxSelectionMs
|
|
521
|
+
const now = Date.now()
|
|
522
|
+
setMode({ kind: 'live', widthMs: capped, all: all || undefined })
|
|
523
|
+
setFrozenAsOfMs(null)
|
|
524
|
+
setNowTick(now)
|
|
525
|
+
resetLensToFull(deriveLiveSelection(capped, now))
|
|
526
|
+
}, [isLocal, scrubberDomain, resetLensToFull])
|
|
527
|
+
|
|
528
|
+
// "→ Now" → LIVE, width = current selection width. Pins to now and resets the
|
|
529
|
+
// lens to the live edge.
|
|
530
|
+
const handleJumpToNow = useCallback(() => {
|
|
531
|
+
const cur = selectionRef.current
|
|
532
|
+
const width = cur.toMs - cur.fromMs
|
|
533
|
+
const now = Date.now()
|
|
534
|
+
setMode({ kind: 'live', widthMs: width })
|
|
535
|
+
setFrozenAsOfMs(null)
|
|
536
|
+
setNowTick(now)
|
|
537
|
+
resetLensToRecent(deriveLiveSelection(width, now))
|
|
538
|
+
}, [resetLensToRecent])
|
|
539
|
+
|
|
540
|
+
// The single scrubber-chip action:
|
|
541
|
+
// frozen → return to LIVE at the current selection width.
|
|
542
|
+
// live + unlatched → re-latch the lens to the live edge (jump to now).
|
|
543
|
+
// live + latched → no-op (already following now).
|
|
544
|
+
const handleLiveChipClick = useCallback(() => {
|
|
545
|
+
if (mode.kind === 'frozen') {
|
|
546
|
+
handleJumpToNow()
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
if (!isLensLatched(lensWindow, selectionRef.current)) {
|
|
550
|
+
resetLensToRecent(selectionRef.current)
|
|
551
|
+
}
|
|
552
|
+
}, [mode.kind, lensWindow, resetLensToRecent, handleJumpToNow])
|
|
553
|
+
|
|
554
|
+
// Extend grows the APPLIED selection 50% in one direction → FROZEN (explicit
|
|
555
|
+
// range action). Reads the current concrete selection so it works from either
|
|
556
|
+
// mode. The lens is preserved inside the now-larger selection.
|
|
557
|
+
const handleExtendRequest = useCallback((dir: 'past' | 'future') => {
|
|
558
|
+
const info = scrubberDomain
|
|
559
|
+
if (!info) return
|
|
560
|
+
const ext = extendSelection(selectionRef.current, dir, info.domain, info.maxSelectionMs)
|
|
561
|
+
setMode({ kind: 'frozen', fromMs: ext.fromMs, toMs: ext.toMs })
|
|
562
|
+
setFrozenAsOfMs(Date.now())
|
|
563
|
+
}, [scrubberDomain])
|
|
564
|
+
|
|
565
|
+
// Live/paused chip state (any scrubber source). `latched` reflects whether the
|
|
566
|
+
// lens still rides the live edge — an unlatched live chip offers a jump-to-now.
|
|
567
|
+
// (The frozen "new events" count is filled in by the scrubber, which owns the
|
|
568
|
+
// overview buckets.) Consumed only where a scrubber renders; inert otherwise.
|
|
569
|
+
const liveState = useMemo<TimelineLiveState | undefined>(() => {
|
|
570
|
+
if (!isRetained && !isLocal) return undefined
|
|
571
|
+
if (mode.kind === 'live') return { kind: 'live', latched: isLensLatched(lensWindow, selection) }
|
|
572
|
+
return { kind: 'frozen', asOfMs: frozenAsOfMs ?? mode.toMs }
|
|
573
|
+
}, [isRetained, isLocal, mode, frozenAsOfMs, lensWindow, selection])
|
|
574
|
+
|
|
575
|
+
// --- URL <-> state binding -------------------------------------------------
|
|
576
|
+
// Two effects with strictly-scoped deps keep the loop from feeding itself:
|
|
577
|
+
// * URL -> state derives every field from searchParams; each setter is guarded
|
|
578
|
+
// to no-op when the value is unchanged. Non-URL deps (e.g. pinnedLanes) can
|
|
579
|
+
// re-run it, but re-deriving from the same URL is idempotent and can't
|
|
580
|
+
// clobber user state back to an old value. It fires on mount and back/forward.
|
|
581
|
+
// * state -> URL keys on the persisted fields (searchParams read via a ref,
|
|
582
|
+
// off the dep list) so it writes on user changes but the browser-driven
|
|
583
|
+
// URL change lands as a no-op (target === current). The live tick moves no
|
|
584
|
+
// persisted field, so it writes nothing.
|
|
585
|
+
const searchParamsRef = useRef(searchParams)
|
|
586
|
+
searchParamsRef.current = searchParams
|
|
587
|
+
// setSearchParams gets a new identity whenever the URL changes (react-router
|
|
588
|
+
// closes over searchParams). If it sat in the write effect's deps, a
|
|
589
|
+
// back/forward navigation would fire the write in the SAME commit as the
|
|
590
|
+
// URL->state read — with pre-sync state — pushing the old URL back. State and
|
|
591
|
+
// URL then swap values every commit until React aborts (#185). Reading the
|
|
592
|
+
// setter through a ref keeps the write keyed on persisted state alone.
|
|
593
|
+
const setSearchParamsRef = useRef(setSearchParams)
|
|
594
|
+
setSearchParamsRef.current = setSearchParams
|
|
595
|
+
const didMountUrlSyncRef = useRef(false)
|
|
596
|
+
|
|
597
|
+
useEffect(() => {
|
|
598
|
+
const sp = searchParams
|
|
599
|
+
const nextView = scopeRequiresNamespaceFilter ? 'list' : (parseView(sp) ?? DEFAULT_VIEW)
|
|
600
|
+
setViewMode((prev) => (prev === nextView ? prev : nextView))
|
|
601
|
+
const nextMode = parseTimeMode(sp, isRetained || isLocal, retainedMaxRangeDays)
|
|
602
|
+
setMode((prev) => (timeModeEqual(prev, nextMode) ? prev : nextMode))
|
|
603
|
+
const nextDeleted = sp.get('deleted') !== '0'
|
|
604
|
+
setShowDeleted((prev) => (prev === nextDeleted ? prev : nextDeleted))
|
|
605
|
+
// Same guard as the lazy init: the param can only arm the filter when pins
|
|
606
|
+
// exist, so a mount that runs this after the empty-pins reset can't re-arm it.
|
|
607
|
+
const nextPinnedOnly = sp.get('pinnedOnly') === '1' && pinnedLanes.length > 0
|
|
608
|
+
setPinnedOnly((prev) => (prev === nextPinnedOnly ? prev : nextPinnedOnly))
|
|
609
|
+
const nextSearch = sp.get('q') ?? ''
|
|
610
|
+
setSearch((prev) => (prev === nextSearch ? prev : nextSearch))
|
|
611
|
+
const nextActivity = parseActivity(sp) ?? []
|
|
612
|
+
setActivityFilter((prev) => (arraysEqual(prev, nextActivity) ? prev : nextActivity))
|
|
613
|
+
const nextKinds = parseKinds(sp)
|
|
614
|
+
setKindFilter((prev) => (arraysEqual(prev, nextKinds) ? prev : nextKinds))
|
|
615
|
+
const nextGrouping = parseEnum(sp.get('grouping'), GROUPINGS, DEFAULT_GROUPING)
|
|
616
|
+
setGrouping((prev) => (prev === nextGrouping ? prev : nextGrouping))
|
|
617
|
+
const nextSort = parseEnum(sp.get('sort'), SORTS, DEFAULT_SORT)
|
|
618
|
+
setSort((prev) => (prev === nextSort ? prev : nextSort))
|
|
619
|
+
const nextEvent = sp.get('event')
|
|
620
|
+
setSelectedEventId((prev) => (prev === nextEvent ? prev : nextEvent))
|
|
621
|
+
}, [searchParams, isRetained, isLocal, scopeRequiresNamespaceFilter, pinnedLanes, retainedMaxRangeDays])
|
|
622
|
+
|
|
623
|
+
useEffect(() => {
|
|
624
|
+
const current = searchParamsRef.current
|
|
625
|
+
const target = writeTimelineParams(
|
|
626
|
+
current,
|
|
627
|
+
{ viewMode, mode, showDeleted, pinnedOnly, search, activityFilter, kindFilter, grouping, sort, selectedEventId },
|
|
628
|
+
{ isRetained: isRetained || isLocal, requiresNamespaceFilter: scopeRequiresNamespaceFilter },
|
|
629
|
+
)
|
|
630
|
+
const targetStr = target.toString()
|
|
631
|
+
const currentStr = current.toString()
|
|
632
|
+
if (targetStr === currentStr) {
|
|
633
|
+
didMountUrlSyncRef.current = true
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
// Mount-time normalization (stripping stale/invalid params, migrating the
|
|
637
|
+
// legacy `filter` seed) must not leave a back entry.
|
|
638
|
+
const replace = !didMountUrlSyncRef.current || onlyHighFreqDiffer(currentStr, targetStr)
|
|
639
|
+
didMountUrlSyncRef.current = true
|
|
640
|
+
setSearchParamsRef.current(target, { replace })
|
|
641
|
+
}, [viewMode, mode, showDeleted, pinnedOnly, search, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
|
|
642
|
+
|
|
643
|
+
// Fetch all activity - zoom controls what's visible in the UI. The heavy 10k
|
|
644
|
+
// ring feeds the swimlanes and the local strip's histogram, so it also runs in
|
|
645
|
+
// list mode when that strip is shown; the list itself fetches its own 2000.
|
|
646
|
+
const { data: activity, isLoading, isError, refetch } = timelineSource.useEvents({
|
|
647
|
+
namespaces: timelineNamespaces,
|
|
53
648
|
timeRange: 'all',
|
|
54
649
|
includeK8sEvents: true,
|
|
55
650
|
includeManaged: true,
|
|
651
|
+
includeDeleted: showDeleted,
|
|
56
652
|
limit: 10000,
|
|
57
|
-
|
|
653
|
+
// The local strip derives its histogram from this ring fetch, so it must
|
|
654
|
+
// run in list mode too whenever the strip is shown.
|
|
655
|
+
enabled: appScopeReady && (showSwimlanes || showLocalScrubber),
|
|
656
|
+
fromMs: isRetained ? selection.fromMs : undefined,
|
|
657
|
+
toMs: isRetained ? selection.toMs : undefined,
|
|
658
|
+
sliding: isRetained && mode.kind === 'live',
|
|
58
659
|
})
|
|
59
660
|
|
|
60
|
-
//
|
|
61
|
-
const { data: rawTopology } = useTopology(
|
|
661
|
+
// Topology powers both swimlane hierarchy and application-scoped attribution.
|
|
662
|
+
const { data: rawTopology } = useTopology(timelineNamespaces, 'resources', {
|
|
663
|
+
enabled: appScopeReady && (showSwimlanes || Boolean(focusedAppKey)),
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
// Server application grouping — the single grouping authority. Joined to the
|
|
667
|
+
// timeline lanes client-side via the membership index. A failed/absent fetch
|
|
668
|
+
// leaves the index undefined; the swimlane degrades to its legacy label
|
|
669
|
+
// grouping (no crash, events still render).
|
|
670
|
+
// App-grouped swimlanes and application-scoped handoffs consume this index.
|
|
671
|
+
// Other Timeline states avoid the background applications poll.
|
|
672
|
+
const {
|
|
673
|
+
data: appsData,
|
|
674
|
+
dataUpdatedAt: appsUpdatedAt,
|
|
675
|
+
isLoading: appsLoading,
|
|
676
|
+
isError: appsError,
|
|
677
|
+
} = useApplications(timelineNamespaces, {
|
|
678
|
+
enabled: appScopeReady && ((showSwimlanes && grouping === 'app') || Boolean(focusedAppKey)),
|
|
679
|
+
})
|
|
680
|
+
const appIndex = useMemo(
|
|
681
|
+
() => (appsData?.applications ? buildAppMembershipIndex(appsData.applications) : undefined),
|
|
682
|
+
// Memoize on the fetch identity, not the array ref: React Query hands back a
|
|
683
|
+
// fresh object each poll even when the data is equal, and rebuilding the
|
|
684
|
+
// index would reshuffle lanes mid-view. dataUpdatedAt only advances on a real
|
|
685
|
+
// successful fetch.
|
|
686
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
687
|
+
[appsUpdatedAt],
|
|
688
|
+
)
|
|
62
689
|
|
|
63
690
|
// Stabilize topology reference to prevent unnecessary lane recomputation
|
|
64
691
|
// Only update the stable topology when the content meaningfully changes
|
|
@@ -71,13 +698,163 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
71
698
|
return rawTopology
|
|
72
699
|
}, [rawTopology])
|
|
73
700
|
|
|
74
|
-
|
|
75
|
-
|
|
701
|
+
const focusedApp = useMemo(
|
|
702
|
+
() => appsData?.applications.find((candidate) => candidate.key === focusedAppKey),
|
|
703
|
+
[appsData?.applications, focusedAppKey],
|
|
704
|
+
)
|
|
705
|
+
const focusedAppIndex = useMemo(
|
|
706
|
+
() => (focusedApp ? buildAppMembershipIndex([focusedApp]) : undefined),
|
|
707
|
+
[focusedApp],
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
// Application History hands off to Timeline with an explicit app scope. Keep
|
|
711
|
+
// that scope local to Timeline rather than changing the global namespace
|
|
712
|
+
// preference, and fail closed while the application cannot be resolved.
|
|
713
|
+
const unscopedEvents = activity ?? EMPTY_EVENTS
|
|
714
|
+
const events = useMemo(
|
|
715
|
+
() => focusedAppKey
|
|
716
|
+
? focusedAppIndex
|
|
717
|
+
? eventsForApplication(unscopedEvents, stableTopology, focusedAppIndex)
|
|
718
|
+
: EMPTY_EVENTS
|
|
719
|
+
: unscopedEvents,
|
|
720
|
+
[focusedAppIndex, focusedAppKey, stableTopology, unscopedEvents],
|
|
721
|
+
)
|
|
722
|
+
const focusedAppLoading = Boolean(focusedAppKey) && appsLoading
|
|
723
|
+
const focusedAppUnavailable = Boolean(focusedAppKey) && (!appScopeReady || (!appsLoading && (appsError || !focusedApp)))
|
|
724
|
+
const focusedAppTimelineLimited = Boolean(focusedAppKey) && unscopedEvents.length >= 10_000
|
|
725
|
+
const clearFocusedApp = useCallback(() => {
|
|
726
|
+
const next = new URLSearchParams(searchParamsRef.current)
|
|
727
|
+
next.delete('app')
|
|
728
|
+
next.delete('scopeNamespaces')
|
|
729
|
+
next.delete('grouping')
|
|
730
|
+
next.delete('window')
|
|
731
|
+
next.delete('from')
|
|
732
|
+
next.delete('to')
|
|
733
|
+
setSearchParamsRef.current(next)
|
|
734
|
+
}, [])
|
|
735
|
+
|
|
736
|
+
const appScopeBar = focusedAppKey ? (
|
|
737
|
+
<div className="flex items-start justify-between gap-3 border-b border-theme-border bg-theme-surface px-4 py-2">
|
|
738
|
+
<div className="min-w-0 space-y-1">
|
|
739
|
+
<div className="flex min-w-0 items-center gap-2 text-sm">
|
|
740
|
+
<Boxes className="h-4 w-4 shrink-0 text-accent" />
|
|
741
|
+
<span className="shrink-0 text-theme-text-tertiary">Application</span>
|
|
742
|
+
{focusedApp ? (
|
|
743
|
+
<button
|
|
744
|
+
type="button"
|
|
745
|
+
onClick={() => handleAppClick(focusedApp.key)}
|
|
746
|
+
className="truncate font-medium text-accent-text hover:underline"
|
|
747
|
+
>
|
|
748
|
+
{focusedApp.name}
|
|
749
|
+
</button>
|
|
750
|
+
) : (
|
|
751
|
+
<span className="truncate font-medium text-theme-text-secondary">
|
|
752
|
+
{focusedAppLoading ? 'Resolving scope...' : 'Scope unavailable'}
|
|
753
|
+
</span>
|
|
754
|
+
)}
|
|
755
|
+
{focusedAppUnavailable && (
|
|
756
|
+
<span className="truncate text-theme-text-tertiary">
|
|
757
|
+
{appScopeReady
|
|
758
|
+
? 'The application is not available in the current cluster view.'
|
|
759
|
+
: 'This link is missing the namespaces needed to resolve the application.'}
|
|
760
|
+
</span>
|
|
761
|
+
)}
|
|
762
|
+
</div>
|
|
763
|
+
{showSwimlanes && focusedAppTimelineLimited && (
|
|
764
|
+
<div className="flex items-center gap-1.5 pl-6 text-xs text-theme-text-tertiary">
|
|
765
|
+
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
|
|
766
|
+
Showing application activity found in the newest 10,000 events in this range. Narrow the range to see older activity.
|
|
767
|
+
</div>
|
|
768
|
+
)}
|
|
769
|
+
</div>
|
|
770
|
+
<button
|
|
771
|
+
type="button"
|
|
772
|
+
onClick={clearFocusedApp}
|
|
773
|
+
className="flex shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
|
|
774
|
+
>
|
|
775
|
+
<X className="h-3.5 w-3.5" />
|
|
776
|
+
Clear scope
|
|
777
|
+
</button>
|
|
778
|
+
</div>
|
|
779
|
+
) : null
|
|
780
|
+
|
|
781
|
+
// The scrubber sits above whichever view is active, sharing one selection
|
|
782
|
+
// across list + swimlane. Retained draws its server-overview strip; local
|
|
783
|
+
// derives the strip client-side from the loaded ring and omits the gap band.
|
|
784
|
+
const wrap = (node: ReactNode): ReactNode => {
|
|
785
|
+
if (!showScrubber) {
|
|
786
|
+
return (
|
|
787
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
788
|
+
{appScopeBar}
|
|
789
|
+
<div className="flex-1 flex flex-col min-h-0">{node}</div>
|
|
790
|
+
</div>
|
|
791
|
+
)
|
|
792
|
+
}
|
|
793
|
+
// In list mode the lens mirrors the rows visible in the list's scrollport
|
|
794
|
+
// (scrolling moves it) — and dragging the band works the OTHER way too: it
|
|
795
|
+
// scrolls the list to that time (two-way, like the swimlane). In swimlane
|
|
796
|
+
// mode it is the interactive zoom window.
|
|
797
|
+
const isListView = viewMode === 'list'
|
|
798
|
+
const lens = isListView ? listVisibleWindow ?? undefined : lensWindow
|
|
799
|
+
const onLensChange = isListView
|
|
800
|
+
? (l: ScrubberRange) => setListScrollToMs(l.toMs)
|
|
801
|
+
: setLens
|
|
802
|
+
return (
|
|
803
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
804
|
+
{appScopeBar}
|
|
805
|
+
{isRetained ? (
|
|
806
|
+
<RetainedTimelineScrubber
|
|
807
|
+
source={timelineSource}
|
|
808
|
+
selection={selection}
|
|
809
|
+
onSelectionChange={handleSelectionChange}
|
|
810
|
+
onSelectionClamp={handleSelectionClamp}
|
|
811
|
+
onPresetSelect={handlePresetSelect}
|
|
812
|
+
lens={lens}
|
|
813
|
+
onLensChange={onLensChange}
|
|
814
|
+
lensResizable={!isListView}
|
|
815
|
+
onDomainChange={setScrubberDomain}
|
|
816
|
+
onGapsChange={setGaps}
|
|
817
|
+
liveState={liveState}
|
|
818
|
+
onLiveChipClick={handleLiveChipClick}
|
|
819
|
+
/>
|
|
820
|
+
) : (
|
|
821
|
+
<LocalTimelineScrubber
|
|
822
|
+
events={events}
|
|
823
|
+
loading={isLoading}
|
|
824
|
+
isError={isError}
|
|
825
|
+
selection={selection}
|
|
826
|
+
onSelectionChange={handleSelectionChange}
|
|
827
|
+
onSelectionClamp={handleSelectionClamp}
|
|
828
|
+
onPresetSelect={handlePresetSelect}
|
|
829
|
+
lens={lens}
|
|
830
|
+
onLensChange={onLensChange}
|
|
831
|
+
lensResizable={!isListView}
|
|
832
|
+
onDomainChange={setScrubberDomain}
|
|
833
|
+
liveState={liveState}
|
|
834
|
+
onLiveChipClick={handleLiveChipClick}
|
|
835
|
+
/>
|
|
836
|
+
)}
|
|
837
|
+
<div className="flex-1 flex flex-col min-h-0">{node}</div>
|
|
838
|
+
</div>
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!appScopeReady) {
|
|
843
|
+
return wrap(
|
|
844
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
|
845
|
+
<AlertTriangle className="h-8 w-8 text-theme-text-tertiary" />
|
|
846
|
+
<h2 className="text-base font-semibold text-theme-text-primary">Application scope is incomplete</h2>
|
|
847
|
+
<p className="max-w-lg text-sm text-theme-text-secondary">
|
|
848
|
+
Reopen Timeline from the application's History tab so its runtime and deployment-source namespaces are included.
|
|
849
|
+
</p>
|
|
850
|
+
</div>,
|
|
851
|
+
)
|
|
852
|
+
}
|
|
76
853
|
|
|
77
854
|
if (viewMode === 'swimlane') {
|
|
78
855
|
// Large cluster without namespace: show picker instead of swimlanes
|
|
79
|
-
if (
|
|
80
|
-
return (
|
|
856
|
+
if (scopeRequiresNamespaceFilter) {
|
|
857
|
+
return wrap(
|
|
81
858
|
<div className="flex-1 flex flex-col">
|
|
82
859
|
{/* Toolbar with view toggle so user can switch back to list */}
|
|
83
860
|
<div className="flex items-center justify-between px-4 py-2 border-b border-theme-border">
|
|
@@ -86,9 +863,9 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
86
863
|
</div>
|
|
87
864
|
<div className="flex-1 flex items-center justify-center">
|
|
88
865
|
<div className="max-w-md w-full mx-4 text-center">
|
|
89
|
-
<div className="bg-theme-surface border border-theme-border rounded-xl shadow-lg p-6">
|
|
866
|
+
<div className="bg-theme-surface border border-theme-border rounded-xl shadow-theme-lg p-6">
|
|
90
867
|
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-skyhook-500/10 flex items-center justify-center">
|
|
91
|
-
<Network className="w-6 h-6 text-
|
|
868
|
+
<Network className="w-6 h-6 text-skyhook-400" />
|
|
92
869
|
</div>
|
|
93
870
|
<h2 className="text-lg font-semibold text-theme-text-primary mb-2">
|
|
94
871
|
Large Cluster Detected
|
|
@@ -110,27 +887,99 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
110
887
|
)
|
|
111
888
|
}
|
|
112
889
|
|
|
113
|
-
|
|
890
|
+
// A failed fetch must not render as the swimlane "No events yet" empty state —
|
|
891
|
+
// that reads as a quiet cluster rather than a load failure.
|
|
892
|
+
if (isError) {
|
|
893
|
+
return wrap(
|
|
894
|
+
<div className="flex-1 flex flex-col">
|
|
895
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-theme-border">
|
|
896
|
+
<div />
|
|
897
|
+
<ViewModeToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
|
898
|
+
</div>
|
|
899
|
+
<div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-3">
|
|
900
|
+
<AlertTriangle className="w-10 h-10 text-amber-400/70" />
|
|
901
|
+
<p className="text-base">Failed to load timeline data</p>
|
|
902
|
+
<button
|
|
903
|
+
onClick={() => refetch()}
|
|
904
|
+
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg hover:bg-theme-hover transition-colors"
|
|
905
|
+
>
|
|
906
|
+
<RefreshCw className="w-3.5 h-3.5" />
|
|
907
|
+
Try again
|
|
908
|
+
</button>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return wrap(
|
|
114
915
|
<TimelineSwimlanes
|
|
115
916
|
events={events}
|
|
116
|
-
isLoading={isLoading}
|
|
917
|
+
isLoading={isLoading || focusedAppLoading}
|
|
117
918
|
onResourceClick={onResourceClick}
|
|
118
919
|
viewMode={viewMode}
|
|
119
920
|
onViewModeChange={setViewMode}
|
|
120
921
|
topology={stableTopology}
|
|
121
|
-
namespaces={
|
|
922
|
+
namespaces={timelineNamespaces}
|
|
923
|
+
showDeleted={showDeleted}
|
|
924
|
+
onShowDeletedChange={setShowDeleted}
|
|
925
|
+
pinnedOnly={pinnedOnly}
|
|
926
|
+
onPinnedOnlyChange={setPinnedOnly}
|
|
927
|
+
search={search}
|
|
928
|
+
onSearchChange={setSearch}
|
|
929
|
+
activityFilter={activityFilter}
|
|
930
|
+
onActivityFilterChange={setActivityFilter}
|
|
931
|
+
kindFilter={kindFilter}
|
|
932
|
+
onKindFilterChange={setKindFilter}
|
|
933
|
+
appIndex={appIndex}
|
|
934
|
+
grouping={grouping}
|
|
935
|
+
onGroupingChange={setGrouping}
|
|
936
|
+
sort={sort}
|
|
937
|
+
onSortChange={setSort}
|
|
938
|
+
viewWindow={showScrubber ? lensWindow : undefined}
|
|
939
|
+
onViewWindowChange={showScrubber ? setLens : undefined}
|
|
940
|
+
bounds={showScrubber ? selection : undefined}
|
|
941
|
+
onExtendRequest={showScrubber ? handleExtendRequest : undefined}
|
|
942
|
+
nowMs={showScrubber ? nowTick : undefined}
|
|
943
|
+
isLive={showScrubber && mode.kind === 'live'}
|
|
944
|
+
onAppClick={handleAppClick}
|
|
945
|
+
gaps={isRetained ? gaps : undefined}
|
|
946
|
+
pinnedLanes={pinnedLanes}
|
|
947
|
+
onTogglePin={togglePin}
|
|
948
|
+
selectedEventId={selectedEventId}
|
|
949
|
+
onSelectedEventChange={setSelectedEventId}
|
|
122
950
|
/>
|
|
123
951
|
)
|
|
124
952
|
}
|
|
125
953
|
|
|
126
|
-
return (
|
|
954
|
+
return wrap(
|
|
127
955
|
<TimelineList
|
|
128
|
-
namespaces={
|
|
956
|
+
namespaces={timelineNamespaces}
|
|
129
957
|
currentView={viewMode}
|
|
130
958
|
onViewChange={setViewMode}
|
|
131
959
|
onResourceClick={onResourceClick}
|
|
132
960
|
initialFilter={initialFilter}
|
|
133
961
|
initialTimeRange={initialTimeRange}
|
|
962
|
+
showDeleted={showDeleted}
|
|
963
|
+
onShowDeletedChange={setShowDeleted}
|
|
964
|
+
search={search}
|
|
965
|
+
onSearchChange={setSearch}
|
|
966
|
+
activityFilter={activityFilter}
|
|
967
|
+
onActivityFilterChange={setActivityFilter}
|
|
968
|
+
kindFilter={kindFilter}
|
|
969
|
+
onKindFilterChange={setKindFilter}
|
|
970
|
+
// The shared selection drives the list ONLY when a scrubber is on screen
|
|
971
|
+
// to own the range — passing it scrubber-less would hide the list's own
|
|
972
|
+
// range dropdown and leave the user with no time control at all.
|
|
973
|
+
selectionWindow={showScrubber ? selection : undefined}
|
|
974
|
+
sliding={showScrubber && mode.kind === 'live'}
|
|
975
|
+
onVisibleWindowChange={setListVisibleWindow}
|
|
976
|
+
// Seeded with the swimlane's window at the switch (see the viewMode
|
|
977
|
+
// effect); afterwards, dragging the strip band retargets the scroll.
|
|
978
|
+
scrollToMs={listScrollToMs}
|
|
979
|
+
focusedAppIndex={focusedAppIndex}
|
|
980
|
+
appScoped={Boolean(focusedAppKey)}
|
|
981
|
+
topology={stableTopology}
|
|
982
|
+
appScopeLoading={focusedAppLoading}
|
|
134
983
|
/>
|
|
135
984
|
)
|
|
136
985
|
}
|