@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.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,8 +1,30 @@
1
- import { useState, useMemo, useRef } from 'react'
2
- import { Network } from 'lucide-react'
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 { useChanges, useTopology } from '../../api/client'
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
- // Force list view on large clusters without namespace filter
43
- const effectiveInitialMode = requiresNamespaceFilter ? 'list' : (initialViewMode ?? 'swimlane')
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' && !requiresNamespaceFilter
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
- // Fetch all activity - zoom controls what's visible in the UI
50
- // Only fetch heavy 10k dataset for swimlanes; list view fetches its own 500
51
- const { data: activity, isLoading } = useChanges({
52
- namespaces,
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
- enabled: showSwimlanes,
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
- // Fetch topology for service stack grouping skip on large clusters (empty anyway)
61
- const { data: rawTopology } = useTopology(namespaces, 'resources', { enabled: showSwimlanes })
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
- // Use stable reference for events to prevent unnecessary re-renders
75
- const events = activity ?? EMPTY_EVENTS
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&apos;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 (requiresNamespaceFilter) {
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-blue-400" />
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
- return (
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={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={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
  }