@skyhook-io/radar-app 1.8.6 → 1.8.7

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