@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
@@ -0,0 +1,580 @@
1
+ // Timeline data-source abstraction.
2
+ //
3
+ // Radar's timeline can be backed by two stores:
4
+ // - 'local' — the in-process event store the Radar binary keeps (default,
5
+ // OSS standalone). Fetched via GET {apiBase}/changes.
6
+ // - 'retained' — a longer-horizon history store answered upstream of Radar
7
+ // (relative to apiBase) as GET {apiBase}/timeline/events and
8
+ // GET {apiBase}/timeline/overview. This is an extension point:
9
+ // the standalone binary never selects it; a host that embeds
10
+ // RadarApp behind a proxy that serves retained history opts in
11
+ // via the `timelineSource` prop.
12
+ //
13
+ // Both sources expose the same `useEvents(query)` hook shape so the timeline
14
+ // wrappers stay source-agnostic: pick the source from context, call useEvents.
15
+ import { useMemo } from 'react'
16
+ import { useQuery, keepPreviousData } from '@tanstack/react-query'
17
+ import { quantizeBaseWindow } from '@skyhook-io/k8s-ui'
18
+ import { useChanges, apiFetch, ApiError, type UseChangesOptions } from './client'
19
+ import { apiUrl, getApiBase } from './config'
20
+ import type { TimelineEvent, TimeRange } from '../types'
21
+
22
+ // The query the wrappers pass. Superset of the local store's params so most
23
+ // call sites don't change shape when switching sources.
24
+ export type TimelineQuery = UseChangesOptions & {
25
+ // Explicit [from,to] window in epoch-ms. When both are set the retained
26
+ // source loads exactly this window (the scrubber's brush selection) instead
27
+ // of deriving one from `timeRange`. The local source can't express a frozen
28
+ // past window server-side, so it loads the whole ring and bounds it to
29
+ // [from,to] client-side.
30
+ fromMs?: number
31
+ toMs?: number
32
+ // LIVE mode: the [from,to] window slides every tick. Quantize the BASE fetch
33
+ // window to fixed steps so the react-query key only changes every few minutes;
34
+ // the precise sliding window is still applied by the client-side filter, and
35
+ // the trailing seam is covered by the live poll. Ignored by the local source.
36
+ sliding?: boolean
37
+ }
38
+
39
+ export interface TimelineSourceCapabilities {
40
+ mode: 'local' | 'retained'
41
+ // Only meaningful for 'retained': the maximum lookback the retained backend
42
+ // serves. Clamps EVERY derived range (rangeSpanMs, not just 'all'), the
43
+ // from-edge of an explicit [from,to] window, and the scrubber's selectable
44
+ // domain — nothing can reach further back than this. Defaults to
45
+ // DEFAULT_RETAINED_MAX_RANGE_DAYS when unset.
46
+ maxRangeDays?: number
47
+ }
48
+
49
+ // A single window [from,to] in epoch-ms. Cluster identity is implicit in the
50
+ // configured apiBase path, so no cluster id is carried here.
51
+ export interface TimelineRange {
52
+ from: number
53
+ to: number
54
+ }
55
+
56
+ // Coverage lines emitted inline in the retained events stream (gaps / retention
57
+ // boundaries). Shape is owned by the retained backend; kept opaque here and
58
+ // surfaced on the result so a future UI can render coverage without another
59
+ // round-trip. Not consumed by the timeline wrappers today.
60
+ export interface TimelineCoverageRecord {
61
+ type: 'coverage'
62
+ [key: string]: unknown
63
+ }
64
+
65
+ // A recording-coverage span within an overview bucket. Event-time bounds are
66
+ // owned by the retained (hub) backend; on the wire a bucket may carry one span
67
+ // or several — fetchRetainedOverview normalizes both to an array so consumers
68
+ // only ever see TimelineCoverageSpan[].
69
+ export interface TimelineCoverageSpan {
70
+ eventTimeStartMs?: number
71
+ eventTimeEndMs?: number
72
+ }
73
+
74
+ export interface TimelineOverviewBucket {
75
+ // Bucket start in epoch-ms — hourly for the retained rollup, sub-hour when the
76
+ // local overview rebuckets finer. Named for what it is, not its granularity.
77
+ startMs: number
78
+ summary: {
79
+ total: number
80
+ adds: number
81
+ updates: number
82
+ deletes: number
83
+ warnings: number
84
+ // Server-owned health rollup. localOverviewFromEvents only emits
85
+ // 'healthy'/'unhealthy', but the retained (hub) rollup can carry the full
86
+ // HealthLevel vocabulary, so this stays an open string.
87
+ worstHealth: string
88
+ namespaces: string[]
89
+ }
90
+ coverage?: TimelineCoverageSpan[]
91
+ }
92
+
93
+ // Overview response envelope: the hourly `buckets` plus `availableFromMs` (the
94
+ // oldest event-time the server holds for this cluster) so the scrubber domain
95
+ // reflects real retention.
96
+ export interface TimelineOverviewResult {
97
+ buckets: TimelineOverviewBucket[]
98
+ availableFromMs?: number
99
+ }
100
+
101
+ export interface TimelineEventsResult {
102
+ data: TimelineEvent[] | undefined
103
+ isLoading: boolean
104
+ isError: boolean
105
+ refetch: () => void
106
+ // Present only for sources that report coverage (retained).
107
+ coverage?: TimelineCoverageRecord[]
108
+ }
109
+
110
+ export interface TimelineSource {
111
+ capabilities: TimelineSourceCapabilities
112
+ useEvents: (query: TimelineQuery) => TimelineEventsResult
113
+ // Optional hourly rollup. Only the retained source implements it.
114
+ fetchOverview?: (range: TimelineRange) => Promise<TimelineOverviewResult>
115
+ }
116
+
117
+ // Config carried by the RadarApp `timelineSource` prop. Absent = local.
118
+ export interface TimelineSourceConfig {
119
+ mode: 'retained'
120
+ maxRangeDays?: number
121
+ }
122
+
123
+ // ============================================================================
124
+ // Local source — thin wrapper over the existing useChanges behavior. Zero
125
+ // behavioral change for OSS standalone.
126
+ // ============================================================================
127
+
128
+ // Full ring size to pull when the host bounds the local list by the shared
129
+ // scrubber selection — matches the swimlane's ring fetch so both views cover the
130
+ // same window.
131
+ const LOCAL_RING_LIMIT = 10000
132
+
133
+ function useLocalEvents(query: TimelineQuery): TimelineEventsResult {
134
+ // Without a window the dropdown-driven `since` fetch is untouched. When the
135
+ // host drives an explicit [from,to] window (the shared scrubber selection),
136
+ // the private range dropdown is bypassed: the local /changes endpoint is
137
+ // `since`-based and can't express a frozen past window, so load the whole ring
138
+ // and bound it to the selection client-side (applyClientFilters), exactly as
139
+ // the swimlane derives its view from the loaded ring.
140
+ const windowed = query.fromMs != null || query.toMs != null
141
+ // deltaSync on every full-ring pull (timeRange 'all' — the swimlane's direct
142
+ // query and the list's windowed one): SSE-driven refetches then transfer only
143
+ // what arrived since the last full load. Dropdown-ranged (`since`) queries
144
+ // stay plain — they're small and their range moves with the clock.
145
+ const { data, isLoading, isError, refetch } = useChanges(
146
+ windowed
147
+ ? { ...query, timeRange: 'all', limit: LOCAL_RING_LIMIT, deltaSync: true }
148
+ : { ...query, deltaSync: query.timeRange === 'all' },
149
+ )
150
+ // applyClientFilters runs on BOTH paths: a multi-kind selection is a CLIENT-side
151
+ // filter (only a single kind rides the /changes server query key), so a 2+ kind
152
+ // pick is narrowed here whether or not a window is set. A non-windowed query has
153
+ // null from/to, so the [from,to] bounding inside is a no-op there; the windowed
154
+ // path additionally bounds the loaded ring. The memo watches kindsKey itself —
155
+ // `data` identity won't change when only the kind set does.
156
+ const kindsKey = query.kinds?.join(',')
157
+ const events = useMemo(
158
+ () => (data ? applyClientFilters(data, query) : data),
159
+ // `data` identity captures every server-side filter change (namespaces,
160
+ // k8s-events, deleted — all in the useChanges query key); the client-only
161
+ // window + cap + kind set are added here, plus includeManaged, which is
162
+ // client-enforced and must not depend on staying in the server key. The
163
+ // live tick advances query.toMs, re-filtering to the sliding edge with no
164
+ // refetch.
165
+ // eslint-disable-next-line react-hooks/exhaustive-deps
166
+ [data, query.fromMs, query.toMs, query.limit, kindsKey, query.includeManaged],
167
+ )
168
+ return { data: events, isLoading, isError, refetch }
169
+ }
170
+
171
+ export const localSource: TimelineSource = {
172
+ capabilities: { mode: 'local' },
173
+ useEvents: useLocalEvents,
174
+ }
175
+
176
+ const LOCAL_HOUR_MS = 60 * 60 * 1000
177
+
178
+ interface LocalHourSlot {
179
+ total: number
180
+ adds: number
181
+ updates: number
182
+ deletes: number
183
+ warnings: number
184
+ namespaces: Set<string>
185
+ }
186
+
187
+ // Client-side overview for the local source. The Radar binary loads the whole
188
+ // event ring into the browser (the swimlane's 10k fetch), so the scrubber
189
+ // histogram is derived here instead of from a server rollup — the local store
190
+ // has no /timeline/overview endpoint. Buckets by hour to match the retained
191
+ // overview shape the scrubber host already groups.
192
+ //
193
+ // `availableFromMs` is the oldest event time held in the ring: the scrubber
194
+ // domain floor comes from whoever holds the data, exactly as the retained
195
+ // source reports its own retention floor.
196
+ //
197
+ // `bucketSizeMs` defaults to the retained rollup's hourly granularity; a
198
+ // tightly framed strip passes a sub-hour size to rebucket the same events
199
+ // finer. The bucket's `startMs` is its start regardless of size — hourly or not.
200
+ //
201
+ // No coverage/gap field is emitted. Coverage is a retention concept — the hub
202
+ // records what it missed while not watching. Locally we cannot know what
203
+ // happened during downtime, so claiming a gap would be dishonest; we omit it.
204
+ export function localOverviewFromEvents(events: TimelineEvent[], bucketSizeMs = LOCAL_HOUR_MS): TimelineOverviewResult {
205
+ const slots = new Map<number, LocalHourSlot>()
206
+ let oldest = Number.POSITIVE_INFINITY
207
+
208
+ for (const e of events) {
209
+ const t = new Date(e.timestamp).getTime()
210
+ if (!Number.isFinite(t)) continue
211
+ if (t < oldest) oldest = t
212
+ const hour = Math.floor(t / bucketSizeMs) * bucketSizeMs
213
+ let slot = slots.get(hour)
214
+ if (!slot) {
215
+ slot = { total: 0, adds: 0, updates: 0, deletes: 0, warnings: 0, namespaces: new Set() }
216
+ slots.set(hour, slot)
217
+ }
218
+ slot.total++
219
+ if (e.eventType === 'add') slot.adds++
220
+ else if (e.eventType === 'update') slot.updates++
221
+ else if (e.eventType === 'delete') slot.deletes++
222
+ if (e.eventType === 'Warning') slot.warnings++
223
+ if (e.namespace) slot.namespaces.add(e.namespace)
224
+ }
225
+
226
+ const buckets: TimelineOverviewBucket[] = [...slots.entries()]
227
+ .map(([startMs, s]) => ({
228
+ startMs,
229
+ summary: {
230
+ total: s.total,
231
+ adds: s.adds,
232
+ updates: s.updates,
233
+ deletes: s.deletes,
234
+ warnings: s.warnings,
235
+ worstHealth: s.warnings > 0 ? 'unhealthy' : 'healthy',
236
+ namespaces: [...s.namespaces],
237
+ },
238
+ }))
239
+ .sort((a, b) => a.startMs - b.startMs)
240
+
241
+ return { buckets, availableFromMs: Number.isFinite(oldest) ? oldest : undefined }
242
+ }
243
+
244
+ // ============================================================================
245
+ // Retained source — streams NDJSON from {apiBase}/timeline/events.
246
+ // ============================================================================
247
+
248
+ const HOUR_MS = 60 * 60 * 1000
249
+ const DAY_MS = 24 * HOUR_MS
250
+
251
+ // Bound for the 'all' range when the host doesn't specify maxRangeDays.
252
+ const DEFAULT_RETAINED_MAX_RANGE_DAYS = 7
253
+
254
+ // Recent window the live poll re-fetches and merges over the loaded range. Wide
255
+ // enough that a missed 10s tick can't open a gap. Exported so a unit test can pin
256
+ // it against the base-window quantization step (quantization lag < poll window).
257
+ export const LIVE_WINDOW_MS = 10 * 60 * 1000
258
+ const LIVE_REFETCH_MS = 10_000
259
+
260
+ function rangeSpanMs(range: TimeRange | undefined, maxRangeDays?: number): number {
261
+ const cap = (maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS) * DAY_MS
262
+ let span: number
263
+ switch (range) {
264
+ case '5m':
265
+ span = 5 * 60 * 1000
266
+ break
267
+ case '30m':
268
+ span = 30 * 60 * 1000
269
+ break
270
+ case '1h':
271
+ span = HOUR_MS
272
+ break
273
+ case '6h':
274
+ span = 6 * HOUR_MS
275
+ break
276
+ case '24h':
277
+ span = 24 * HOUR_MS
278
+ break
279
+ case '7d':
280
+ span = 7 * DAY_MS
281
+ break
282
+ case '30d':
283
+ span = 30 * DAY_MS
284
+ break
285
+ case 'all':
286
+ case undefined:
287
+ span = cap
288
+ break
289
+ default:
290
+ span = HOUR_MS
291
+ }
292
+ return Math.min(span, cap)
293
+ }
294
+
295
+ interface RetainedWindowResult {
296
+ events: TimelineEvent[]
297
+ coverage: TimelineCoverageRecord[]
298
+ }
299
+
300
+ type TerminalRecord = { type: 'end' } | { type: 'error'; message?: string }
301
+
302
+ // De-dupe by id keeping the LAST occurrence — a later revision of an event
303
+ // replaces the earlier one.
304
+ function dedupeById(events: TimelineEvent[]): TimelineEvent[] {
305
+ const byId = new Map<string, TimelineEvent>()
306
+ for (const e of events) byId.set(e.id, e)
307
+ return Array.from(byId.values())
308
+ }
309
+
310
+ // Exported for unit tests (the NDJSON stream parser); not re-exported publicly.
311
+ export async function fetchRetainedWindow(
312
+ from: number,
313
+ to: number,
314
+ signal?: AbortSignal,
315
+ ): Promise<RetainedWindowResult> {
316
+ const res = await apiFetch(
317
+ apiUrl(`/timeline/events?from=${Math.round(from)}&to=${Math.round(to)}`),
318
+ signal ? { signal } : undefined,
319
+ )
320
+ if (!res.ok) {
321
+ const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
322
+ throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
323
+ }
324
+ if (!res.body) {
325
+ throw new Error('timeline stream has no body')
326
+ }
327
+
328
+ const reader = res.body.getReader()
329
+ const decoder = new TextDecoder()
330
+ const events: TimelineEvent[] = []
331
+ const coverage: TimelineCoverageRecord[] = []
332
+ let terminal: TerminalRecord | null = null
333
+ let buf = ''
334
+
335
+ const handleLine = (line: string): void => {
336
+ const trimmed = line.trim()
337
+ if (!trimmed) return
338
+ const rec = JSON.parse(trimmed) as { type?: string; message?: string }
339
+ if (rec.type === 'end') {
340
+ terminal = { type: 'end' }
341
+ } else if (rec.type === 'error') {
342
+ terminal = { type: 'error', message: rec.message }
343
+ } else if (rec.type === 'coverage') {
344
+ coverage.push(rec as TimelineCoverageRecord)
345
+ } else {
346
+ events.push(rec as unknown as TimelineEvent)
347
+ }
348
+ }
349
+
350
+ for (;;) {
351
+ const { done, value } = await reader.read()
352
+ if (done) break
353
+ buf += decoder.decode(value, { stream: true })
354
+ let idx: number
355
+ while ((idx = buf.indexOf('\n')) >= 0) {
356
+ handleLine(buf.slice(0, idx))
357
+ buf = buf.slice(idx + 1)
358
+ }
359
+ }
360
+ handleLine(buf)
361
+
362
+ // Absence of a terminal record means the response was truncated — treat as a
363
+ // failure so the UI doesn't render a partial window as complete.
364
+ if (!terminal) {
365
+ throw new Error('timeline stream truncated (missing terminal record)')
366
+ }
367
+ if ((terminal as TerminalRecord).type === 'error') {
368
+ throw new Error((terminal as { message?: string }).message || 'timeline stream error')
369
+ }
370
+
371
+ return { events: dedupeById(events), coverage }
372
+ }
373
+
374
+ // Mirrors the Go store's TimelineEvent.IsManaged (pkg/timeline/types.go):
375
+ // a resource managed by another — owned, or one of the churn kinds. Keep the
376
+ // two predicates in lockstep.
377
+ function isManagedTimelineEvent(e: TimelineEvent): boolean {
378
+ return e.owner != null || e.kind === 'ReplicaSet' || e.kind === 'Pod' || e.kind === 'Event'
379
+ }
380
+
381
+ // The retained endpoint scopes only by [from,to] (cluster is implicit in the
382
+ // apiBase path), so the store-side query params the local endpoint honors are
383
+ // applied client-side over the loaded window. Exported for unit tests; not part
384
+ // of the package's public surface.
385
+ export function applyClientFilters(events: TimelineEvent[], query: TimelineQuery): TimelineEvent[] {
386
+ let out = events
387
+ if (query.namespaces && query.namespaces.length > 0) {
388
+ const set = new Set(query.namespaces)
389
+ out = out.filter((e) => set.has(e.namespace))
390
+ }
391
+ if (query.kinds && query.kinds.length > 0) {
392
+ const set = new Set(query.kinds)
393
+ out = out.filter((e) => set.has(e.kind))
394
+ }
395
+ if (query.includeK8sEvents === false) {
396
+ out = out.filter((e) => e.source !== 'k8s_event')
397
+ }
398
+ if (query.includeDeleted === false) {
399
+ out = out.filter((e) => e.eventType !== 'delete')
400
+ }
401
+ // Enforced here — not only server-side — so both sources honor it
402
+ // identically: the retained endpoint has no include_managed param, and the
403
+ // local path's filter presets can override the server-side flag (the 'all'
404
+ // preset re-includes managed). Only an explicit false filters; the default
405
+ // keeps machinery rows, which the swimlane's pod/RS child lanes require.
406
+ if (query.includeManaged === false) {
407
+ out = out.filter((e) => !isManagedTimelineEvent(e))
408
+ }
409
+ // An explicit brush window bounds the result by event time so the live poll's
410
+ // recent-window merge can't leak events past the selected [from,to].
411
+ if (query.fromMs != null || query.toMs != null) {
412
+ out = out.filter((e) => {
413
+ const t = new Date(e.timestamp).getTime()
414
+ if (query.fromMs != null && t < query.fromMs) return false
415
+ if (query.toMs != null && t > query.toMs) return false
416
+ return true
417
+ })
418
+ }
419
+ out = [...out].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
420
+ if (query.limit && out.length > query.limit) {
421
+ out = out.slice(0, query.limit)
422
+ }
423
+ return out
424
+ }
425
+
426
+ function mergeWindows(
427
+ base: RetainedWindowResult | undefined,
428
+ live: RetainedWindowResult | undefined,
429
+ ): RetainedWindowResult {
430
+ // live is newer, so it overwrites base for shared ids.
431
+ const events = dedupeById([...(base?.events ?? []), ...(live?.events ?? [])])
432
+ const coverage = [...(base?.coverage ?? []), ...(live?.coverage ?? [])]
433
+ return { events, coverage }
434
+ }
435
+
436
+ function createRetainedEventsHook(
437
+ capabilities: TimelineSourceCapabilities,
438
+ ): (query: TimelineQuery) => TimelineEventsResult {
439
+ return function useRetainedEvents(query: TimelineQuery): TimelineEventsResult {
440
+ const enabled = query.enabled ?? true
441
+
442
+ // Pin the base window when the range (or cap) changes — not on every render —
443
+ // so the query key is stable and react-query can cache it. Recency is the
444
+ // live poll's job.
445
+ const window = useMemo<TimelineRange>(() => {
446
+ if (query.fromMs != null && query.toMs != null) {
447
+ // An explicit window (frozen brush, or a hand-entered ?from&to) must not
448
+ // reach further back than maxRangeDays — the same cap rangeSpanMs applies
449
+ // to the range-derived branch below. Anchor at the recent edge.
450
+ const capMs = (capabilities.maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS) * DAY_MS
451
+ const from = Math.max(query.fromMs, query.toMs - capMs)
452
+ // Sliding: quantize so two ticks inside one step share a query key (no
453
+ // refetch). The precise [from,to] is still enforced by applyClientFilters.
454
+ if (query.sliding) {
455
+ const q = quantizeBaseWindow(from, query.toMs)
456
+ return { from: q.fromMs, to: q.toMs }
457
+ }
458
+ return { from, to: query.toMs }
459
+ }
460
+ const to = Date.now()
461
+ return { from: to - rangeSpanMs(query.timeRange, capabilities.maxRangeDays), to }
462
+ // eslint-disable-next-line react-hooks/exhaustive-deps
463
+ }, [query.fromMs, query.toMs, query.timeRange, query.sliding, capabilities.maxRangeDays])
464
+
465
+ const base = useQuery<RetainedWindowResult>({
466
+ // apiBase scopes the key: a host that swaps clusters (mutable module global)
467
+ // must not serve the previous cluster's cached window.
468
+ queryKey: ['timeline-retained', getApiBase(), 'base', window.from, window.to],
469
+ queryFn: ({ signal }) => fetchRetainedWindow(window.from, window.to, signal),
470
+ enabled,
471
+ staleTime: LIVE_REFETCH_MS,
472
+ // The base window quantizes to fixed steps, so its key rotates every few
473
+ // minutes even while live. Hold the previous window's events through the
474
+ // refetch instead of blanking the range on each rotation.
475
+ placeholderData: keepPreviousData,
476
+ })
477
+
478
+ // Fixed recent-window re-poll. Key is stable; queryFn reads the clock fresh
479
+ // each tick so the window slides without churning the cache key. Skipped for
480
+ // a purely historical brush (its right edge is older than the live window),
481
+ // where recency merging would only add events the time filter drops.
482
+ const liveEnabled = enabled && (query.toMs == null || query.toMs >= Date.now() - LIVE_WINDOW_MS)
483
+ const live = useQuery<RetainedWindowResult>({
484
+ queryKey: ['timeline-retained', getApiBase(), 'live'],
485
+ queryFn: ({ signal }) => {
486
+ const to = Date.now()
487
+ return fetchRetainedWindow(to - LIVE_WINDOW_MS, to, signal)
488
+ },
489
+ enabled: liveEnabled,
490
+ refetchInterval: LIVE_REFETCH_MS,
491
+ })
492
+
493
+ const merged = useMemo(
494
+ () => mergeWindows(base.data, live.data),
495
+ [base.data, live.data],
496
+ )
497
+
498
+ const kindsKey = query.kinds?.join(',')
499
+ const data = useMemo(() => {
500
+ if (base.data === undefined && live.data === undefined) return undefined
501
+ return applyClientFilters(merged.events, query)
502
+ // eslint-disable-next-line react-hooks/exhaustive-deps
503
+ }, [
504
+ merged,
505
+ base.data,
506
+ live.data,
507
+ query.namespaces,
508
+ kindsKey,
509
+ query.includeK8sEvents,
510
+ query.includeDeleted,
511
+ query.includeManaged,
512
+ query.limit,
513
+ query.fromMs,
514
+ query.toMs,
515
+ ])
516
+
517
+ return {
518
+ data,
519
+ isLoading: base.isLoading,
520
+ // Base failure is a real error; a failing live poll must not blank an
521
+ // already-loaded range.
522
+ isError: base.isError,
523
+ refetch: () => {
524
+ base.refetch()
525
+ live.refetch()
526
+ },
527
+ coverage: merged.coverage,
528
+ }
529
+ }
530
+ }
531
+
532
+ // The retained (hub) wire shape: the bucket start ships as `hourStartMs` and
533
+ // coverage may be a single span or an array. fetchRetainedOverview remaps this
534
+ // to the client `TimelineOverviewBucket` (startMs + a normalized coverage array)
535
+ // at this one parse boundary so nothing downstream sees the wire quirks.
536
+ interface RawOverviewBucket {
537
+ hourStartMs: number
538
+ summary: TimelineOverviewBucket['summary']
539
+ coverage?: TimelineCoverageSpan | TimelineCoverageSpan[]
540
+ }
541
+
542
+ function normalizeCoverage(
543
+ cov: TimelineCoverageSpan | TimelineCoverageSpan[] | undefined,
544
+ ): TimelineCoverageSpan[] | undefined {
545
+ if (cov == null) return undefined
546
+ return Array.isArray(cov) ? cov : [cov]
547
+ }
548
+
549
+ async function fetchRetainedOverview(range: TimelineRange): Promise<TimelineOverviewResult> {
550
+ const res = await apiFetch(apiUrl(`/timeline/overview?from=${Math.round(range.from)}&to=${Math.round(range.to)}`))
551
+ if (!res.ok) {
552
+ const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
553
+ throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
554
+ }
555
+ const body: unknown = await res.json()
556
+ const env = body as { buckets?: RawOverviewBucket[]; availableFromMs?: number }
557
+ const buckets: TimelineOverviewBucket[] = (env.buckets ?? []).map((b) => ({
558
+ startMs: b.hourStartMs,
559
+ summary: b.summary,
560
+ coverage: normalizeCoverage(b.coverage),
561
+ }))
562
+ return { buckets, availableFromMs: env.availableFromMs }
563
+ }
564
+
565
+ export function createRetainedSource(config: TimelineSourceConfig): TimelineSource {
566
+ const capabilities: TimelineSourceCapabilities = {
567
+ mode: 'retained',
568
+ maxRangeDays: config.maxRangeDays,
569
+ }
570
+ return {
571
+ capabilities,
572
+ useEvents: createRetainedEventsHook(capabilities),
573
+ fetchOverview: fetchRetainedOverview,
574
+ }
575
+ }
576
+
577
+ export function resolveTimelineSource(config?: TimelineSourceConfig): TimelineSource {
578
+ if (config?.mode === 'retained') return createRetainedSource(config)
579
+ return localSource
580
+ }