@skyhook-io/radar-app 1.8.13 → 1.9.0

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.
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Radar's timeline can be backed by two stores:
4
4
  // - 'local' — the in-process event store the Radar binary keeps (default,
5
- // OSS standalone). Fetched via GET {apiBase}/changes.
5
+ // OSS standalone). Fetched via GET {apiBase}/timeline/events.
6
6
  // - 'retained' — a longer-horizon history store answered upstream of Radar
7
7
  // (relative to apiBase) as GET {apiBase}/timeline/events and
8
8
  // GET {apiBase}/timeline/overview. This is an extension point:
@@ -12,10 +12,10 @@
12
12
  //
13
13
  // Both sources expose the same `useEvents(query)` hook shape so the timeline
14
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'
15
+ import { useEffect, useMemo, useState } from 'react'
16
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
17
+ import { LIVE_TICK_MS } from '@skyhook-io/k8s-ui'
18
+ import { apiFetch, mergeDeltaEvents, ApiError, type UseChangesOptions } from './client'
19
19
  import { apiUrl, getApiBase } from './config'
20
20
  import type { TimelineEvent, TimeRange } from '../types'
21
21
 
@@ -29,15 +29,14 @@ export type TimelineQuery = UseChangesOptions & {
29
29
  // [from,to] client-side.
30
30
  fromMs?: number
31
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
32
  }
38
33
 
39
34
  export interface TimelineSourceCapabilities {
40
35
  mode: 'local' | 'retained'
36
+ // The server's per-request row ceiling for this source — the number the
37
+ // truncation copy must cite (the local binary pages at 10k, a retained
38
+ // backend at 50k).
39
+ ringLimit: number
41
40
  // Only meaningful for 'retained': the maximum lookback the retained backend
42
41
  // serves. Clamps EVERY derived range (rangeSpanMs, not just 'all'), the
43
42
  // from-edge of an explicit [from,to] window, and the scrubber's selectable
@@ -101,11 +100,29 @@ export interface TimelineOverviewResult {
101
100
  export interface TimelineEventsResult {
102
101
  data: TimelineEvent[] | undefined
103
102
  isLoading: boolean
103
+ // Broad "is loading" signal: any fetch for the requested data is in flight,
104
+ // INCLUDING routine background polls (every ~10s on the retained source).
105
+ // Do not drive user-visible loaders from this — they would flicker on every
106
+ // poll; use isLoading (true only with no data at all) for loaders. Period
107
+ // changes re-window the loaded ring client-side in both sources, so they
108
+ // never fetch and never signal either flag; the one exception is a frozen
109
+ // [from,to] selection reaching below a truncated ring, which fetches its
110
+ // own window once and signals both flags while it does.
104
111
  isFetching: boolean
105
112
  isError: boolean
113
+ // The failure behind isError, when one is available. Surfaced so the UI can
114
+ // show the server's actionable message (e.g. the retained row-cap "narrow the
115
+ // from/to range") instead of a generic "failed to load".
116
+ error?: Error | null
106
117
  refetch: () => void
107
118
  // Present only for sources that report coverage (retained).
108
119
  coverage?: TimelineCoverageRecord[]
120
+ // The load serving the data was row-capped, so the OLDEST part of what it
121
+ // asked for is not loaded — the ring load when the ring serves, the window
122
+ // load when a frozen selection fetched its own window. Hosts must surface
123
+ // this — rendering a truncated result without a note reads as "nothing
124
+ // older happened".
125
+ truncated?: boolean
109
126
  }
110
127
 
111
128
  export interface TimelineSource {
@@ -122,57 +139,17 @@ export interface TimelineSourceConfig {
122
139
  }
123
140
 
124
141
  // ============================================================================
125
- // Local source — thin wrapper over the existing useChanges behavior. Zero
126
- // behavioral change for OSS standalone.
142
+ // Local source — the same ring-and-delta hook as retained, pointed at the
143
+ // Radar binary's own /timeline/events endpoint. One mechanism, two depths.
127
144
  // ============================================================================
128
145
 
129
- // Full ring size to pull when the host bounds the local list by the shared
130
- // scrubber selection matches the swimlane's ring fetch so both views cover the
131
- // same window.
146
+ // The local binary's per-request row ceiling (its /timeline/events pages at
147
+ // 10k, matching the default in-process ring size).
132
148
  const LOCAL_RING_LIMIT = 10000
133
149
 
134
- function useLocalEvents(query: TimelineQuery): TimelineEventsResult {
135
- // Without a window the dropdown-driven `since` fetch is untouched. When the
136
- // host drives an explicit [from,to] window (the shared scrubber selection),
137
- // the private range dropdown is bypassed: the local /changes endpoint is
138
- // `since`-based and can't express a frozen past window, so load the whole ring
139
- // and bound it to the selection client-side (applyClientFilters), exactly as
140
- // the swimlane derives its view from the loaded ring.
141
- const windowed = query.fromMs != null || query.toMs != null
142
- // deltaSync on every full-ring pull (timeRange 'all' — the swimlane's direct
143
- // query and the list's windowed one): SSE-driven refetches then transfer only
144
- // what arrived since the last full load. Dropdown-ranged (`since`) queries
145
- // stay plain — they're small and their range moves with the clock.
146
- const { data, isLoading, isFetching, isError, refetch } = useChanges(
147
- windowed
148
- ? { ...query, timeRange: 'all', limit: LOCAL_RING_LIMIT, deltaSync: true }
149
- : { ...query, deltaSync: query.timeRange === 'all' },
150
- )
151
- // applyClientFilters runs on BOTH paths: a multi-kind selection is a CLIENT-side
152
- // filter (only a single kind rides the /changes server query key), so a 2+ kind
153
- // pick is narrowed here whether or not a window is set. A non-windowed query has
154
- // null from/to, so the [from,to] bounding inside is a no-op there; the windowed
155
- // path additionally bounds the loaded ring. The memo watches kindsKey itself —
156
- // `data` identity won't change when only the kind set does.
157
- const kindsKey = query.kinds?.join(',')
158
- const events = useMemo(
159
- () => (data ? applyClientFilters(data, query) : data),
160
- // `data` identity captures every server-side filter change (namespaces,
161
- // k8s-events, deleted — all in the useChanges query key); the client-only
162
- // window + cap + kind set are added here, plus includeManaged, which is
163
- // client-enforced and must not depend on staying in the server key. The
164
- // live tick advances query.toMs, re-filtering to the sliding edge with no
165
- // refetch.
166
- // eslint-disable-next-line react-hooks/exhaustive-deps
167
- [data, query.fromMs, query.toMs, query.limit, kindsKey, query.includeManaged],
168
- )
169
- return { data: events, isLoading, isFetching, isError, refetch }
170
- }
171
-
172
- export const localSource: TimelineSource = {
173
- capabilities: { mode: 'local' },
174
- useEvents: useLocalEvents,
175
- }
150
+ // The local anti-entropy cadence: a full ring reload is a cheap same-host
151
+ // request, so server-side evictions surface within minutes.
152
+ const LOCAL_FULL_RESYNC_MS = 5 * 60 * 1000
176
153
 
177
154
  const LOCAL_HOUR_MS = 60 * 60 * 1000
178
155
 
@@ -252,53 +229,85 @@ const DAY_MS = 24 * HOUR_MS
252
229
  // Bound for the 'all' range when the host doesn't specify maxRangeDays.
253
230
  const DEFAULT_RETAINED_MAX_RANGE_DAYS = 7
254
231
 
255
- // Recent window the live poll re-fetches and merges over the loaded range. Wide
256
- // enough that a missed 10s tick can't open a gap. Exported so a unit test can pin
257
- // it against the base-window quantization step (quantization lag < poll window).
258
- export const LIVE_WINDOW_MS = 10 * 60 * 1000
259
- const LIVE_REFETCH_MS = 10_000
260
-
261
- function rangeSpanMs(range: TimeRange | undefined, maxRangeDays?: number): number {
262
- const cap = (maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS) * DAY_MS
263
- let span: number
232
+ // The retained source is the SAME accumulate model as the local source,
233
+ // pointed at the hub: one ring load (the newest RETAINED_RING_LIMIT events
234
+ // over the retention depth), then a poll for everything INGESTED since a
235
+ // server-issued cursor. Ingestion order is what makes the poll complete —
236
+ // a late-arriving or revised event has an old event time but a fresh
237
+ // ingestion time, so it rides the same poll as brand-new events. Period
238
+ // changes re-window the loaded ring client-side; no fetch.
239
+
240
+ // Matches the hub's per-response row cap. A window holding more arrives as
241
+ // the newest RETAINED_RING_LIMIT events with `truncated` set — ring
242
+ // semantics, exactly like the local source's capped ring, never a silent cut.
243
+ export const RETAINED_RING_LIMIT = 50_000
244
+
245
+ // Depth ceiling of the initial ring load; the hub rejects wider windows.
246
+ const RETAINED_MAX_DEPTH_DAYS = 31
247
+
248
+ // Delta poll cadence. Same order as the local source's SSE-nudged refetches.
249
+ const RETAINED_POLL_MS = 10_000
250
+
251
+ // A row-capped delta page sets `more`; the fetch pages forward immediately,
252
+ // bounded so a pathological feed can't spin a single poll forever.
253
+ const RETAINED_DELTA_MAX_PAGES = 10
254
+
255
+ // Anti-entropy: a periodic full ring reload, the retained twin of the local
256
+ // source's FULL_RESYNC_MS. Catches what deltas structurally can't — server-
257
+ // side deletions, coverage-gap updates, and a stale truncated flag. Hourly,
258
+ // not the local 5 minutes: a full ring is a heavy transfer and the delta feed
259
+ // carries revisions, so entropy accumulates far slower here.
260
+ const RETAINED_FULL_RESYNC_MS = 60 * 60 * 1000
261
+
262
+ // Client-vs-hub clock skew allowance, applied at both edges of the ring:
263
+ // the initial window's recent edge extends past the client clock by this
264
+ // slack (a clock BEHIND the hub would otherwise exclude already-ingested
265
+ // events in (clientNow, hubNow] — and the delta cursor already covers them,
266
+ // so no poll would ever deliver them; the window slides back by the same
267
+ // slack to stay within the hub's maximum range), and the delta-merge prune
268
+ // floor sits below the retention depth by the same slack (a clock AHEAD of
269
+ // the hub would otherwise prune oldest events the hub still retains).
270
+ export const RETAINED_CLOCK_SKEW_SLACK_MS = 5 * 60 * 1000
271
+
272
+ // How often a preset-derived window's sliding lower bound refreshes on an
273
+ // IDLE ring (the no-op cached return keeps ring identity stable, so the memo
274
+ // otherwise never re-samples the clock and a "24h" label slowly overstays).
275
+ const RETAINED_PRESET_TICK_MS = 5 * 60 * 1000
276
+
277
+ // The client-side spans the `timeRange` presets resolve to when no explicit
278
+ // [from,to] window rides the query — the retained twin of the local source's
279
+ // server-side `since` resolution. Preset arms return their nominal span; only
280
+ // 'all'/unset falls back to the ring depth — clamping a preset wider than a
281
+ // shallow host is the CALLER's job (Math.min at the use site). Exported for
282
+ // unit tests; not re-exported publicly.
283
+ export function rangeSpanMs(range: TimeRange | undefined, capMs?: number): number | undefined {
264
284
  switch (range) {
265
- case '5m':
266
- span = 5 * 60 * 1000
267
- break
268
- case '30m':
269
- span = 30 * 60 * 1000
270
- break
271
- case '1h':
272
- span = HOUR_MS
273
- break
274
- case '6h':
275
- span = 6 * HOUR_MS
276
- break
277
- case '24h':
278
- span = 24 * HOUR_MS
279
- break
280
- case '7d':
281
- span = 7 * DAY_MS
282
- break
283
- case '30d':
284
- span = 30 * DAY_MS
285
- break
286
- case 'all':
287
- case undefined:
288
- span = cap
289
- break
290
- default:
291
- span = HOUR_MS
285
+ case '5m': return 5 * 60 * 1000
286
+ case '30m': return 30 * 60 * 1000
287
+ case '1h': return HOUR_MS
288
+ case '6h': return 6 * HOUR_MS
289
+ case '24h': return 24 * HOUR_MS
290
+ case '7d': return 7 * DAY_MS
291
+ case '30d': return 30 * DAY_MS
292
+ default: return capMs
292
293
  }
293
- return Math.min(span, cap)
294
294
  }
295
295
 
296
296
  interface RetainedWindowResult {
297
297
  events: TimelineEvent[]
298
298
  coverage: TimelineCoverageRecord[]
299
+ // Next delta cursor (opaque, server-issued). Absent when talking to a hub
300
+ // that predates the delta feed.
301
+ cursor?: string
302
+ // Delta page was row-capped; poll again immediately from `cursor`.
303
+ more: boolean
304
+ // Ring load shipped only the newest slice of the window.
305
+ truncated: boolean
299
306
  }
300
307
 
301
- type TerminalRecord = { type: 'end' } | { type: 'error'; message?: string }
308
+ type TerminalRecord =
309
+ | { type: 'end'; cursor?: string; more?: boolean; truncated?: boolean }
310
+ | { type: 'error'; message?: string }
302
311
 
303
312
  // De-dupe by id keeping the LAST occurrence — a later revision of an event
304
313
  // replaces the earlier one.
@@ -308,16 +317,9 @@ function dedupeById(events: TimelineEvent[]): TimelineEvent[] {
308
317
  return Array.from(byId.values())
309
318
  }
310
319
 
311
- // Exported for unit tests (the NDJSON stream parser); not re-exported publicly.
312
- export async function fetchRetainedWindow(
313
- from: number,
314
- to: number,
315
- signal?: AbortSignal,
316
- ): Promise<RetainedWindowResult> {
317
- const res = await apiFetch(
318
- apiUrl(`/timeline/events?from=${Math.round(from)}&to=${Math.round(to)}`),
319
- signal ? { signal } : undefined,
320
- )
320
+ // Shared NDJSON stream reader for both events endpoints (window and delta).
321
+ async function fetchRetainedStream(path: string, signal?: AbortSignal): Promise<RetainedWindowResult> {
322
+ const res = await apiFetch(apiUrl(path), signal ? { signal } : undefined)
321
323
  if (!res.ok) {
322
324
  const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
323
325
  throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
@@ -336,9 +338,9 @@ export async function fetchRetainedWindow(
336
338
  const handleLine = (line: string): void => {
337
339
  const trimmed = line.trim()
338
340
  if (!trimmed) return
339
- const rec = JSON.parse(trimmed) as { type?: string; message?: string }
341
+ const rec = JSON.parse(trimmed) as { type?: string; message?: string; cursor?: string; more?: boolean; truncated?: boolean }
340
342
  if (rec.type === 'end') {
341
- terminal = { type: 'end' }
343
+ terminal = { type: 'end', cursor: rec.cursor, more: rec.more, truncated: rec.truncated }
342
344
  } else if (rec.type === 'error') {
343
345
  terminal = { type: 'error', message: rec.message }
344
346
  } else if (rec.type === 'coverage') {
@@ -365,11 +367,56 @@ export async function fetchRetainedWindow(
365
367
  if (!terminal) {
366
368
  throw new Error('timeline stream truncated (missing terminal record)')
367
369
  }
368
- if ((terminal as TerminalRecord).type === 'error') {
369
- throw new Error((terminal as { message?: string }).message || 'timeline stream error')
370
+ const end = terminal as TerminalRecord
371
+ if (end.type === 'error') {
372
+ throw new Error(end.message || 'timeline stream error')
370
373
  }
374
+ return {
375
+ events: dedupeById(events),
376
+ coverage,
377
+ cursor: end.cursor,
378
+ more: end.more === true,
379
+ truncated: end.truncated === true,
380
+ }
381
+ }
371
382
 
372
- return { events: dedupeById(events), coverage }
383
+ // The ring load: newest `limit` events overlapping [from, to], plus the delta
384
+ // cursor to continue from. Exported for unit tests; not re-exported publicly.
385
+ export async function fetchRetainedWindow(
386
+ from: number,
387
+ to: number,
388
+ signal?: AbortSignal,
389
+ limit?: number,
390
+ namespaces?: string[],
391
+ ): Promise<RetainedWindowResult> {
392
+ const limitParam = limit ? `&limit=${limit}` : ''
393
+ return fetchRetainedStream(
394
+ `/timeline/events?from=${Math.round(from)}&to=${Math.round(to)}${limitParam}${namespacesParam(namespaces)}`,
395
+ signal,
396
+ )
397
+ }
398
+
399
+ // A namespace-scoped consumer must scope the SERVER query, not just the
400
+ // client filter: the ring is row-capped, so an all-namespace ring on a busy
401
+ // cluster could spend its whole budget on other namespaces' events. A server
402
+ // that scopes only by time ignores the parameter and the client filter still
403
+ // applies.
404
+ function namespacesParam(namespaces?: string[]): string {
405
+ if (!namespaces || namespaces.length === 0) return ''
406
+ return `&namespaces=${encodeURIComponent(namespaces.join(','))}`
407
+ }
408
+
409
+ // The delta poll: everything ingested since the cursor, in ingestion order.
410
+ // Exported for unit tests; not re-exported publicly.
411
+ export async function fetchRetainedDelta(
412
+ cursor: string,
413
+ signal?: AbortSignal,
414
+ namespaces?: string[],
415
+ ): Promise<RetainedWindowResult> {
416
+ return fetchRetainedStream(
417
+ `/timeline/events?since=${encodeURIComponent(cursor)}${namespacesParam(namespaces)}`,
418
+ signal,
419
+ )
373
420
  }
374
421
 
375
422
  // Mirrors the Go store's TimelineEvent.IsManaged (pkg/timeline/types.go):
@@ -424,88 +471,337 @@ export function applyClientFilters(events: TimelineEvent[], query: TimelineQuery
424
471
  return out
425
472
  }
426
473
 
427
- function mergeWindows(
428
- base: RetainedWindowResult | undefined,
429
- live: RetainedWindowResult | undefined,
430
- ): RetainedWindowResult {
431
- // live is newer, so it overwrites base for shared ids.
432
- const events = dedupeById([...(base?.events ?? []), ...(live?.events ?? [])])
433
- const coverage = [...(base?.coverage ?? []), ...(live?.coverage ?? [])]
434
- return { events, coverage }
474
+ // The retained ring: what the single query holds. Same shape idea as the
475
+ // local source's cached page, plus retention extras (coverage, truncation).
476
+ export interface RetainedRing {
477
+ events: TimelineEvent[]
478
+ coverage: TimelineCoverageRecord[]
479
+ truncated: boolean
480
+ // The delta cursor rides the SAME react-query commit as the events it
481
+ // describes: an aborted or discarded fetch discards both together, so the
482
+ // cursor can never advance past events that were thrown away. Absent = the
483
+ // hub predates the delta feed; polls become no-ops (manual refresh still
484
+ // resyncs) instead of hammering it with a full reload every tick.
485
+ cursor?: string
486
+ // When the last FULL ring load ran — the anti-entropy resync clock.
487
+ loadedAtMs: number
435
488
  }
436
489
 
437
- function createRetainedEventsHook(
438
- capabilities: TimelineSourceCapabilities,
439
- ): (query: TimelineQuery) => TimelineEventsResult {
440
- return function useRetainedEvents(query: TimelineQuery): TimelineEventsResult {
441
- const enabled = query.enabled ?? true
490
+ // Whether an explicit [from,to] selection reaches below what the loaded ring
491
+ // holds. A truncated ring kept only the NEWEST rows, so a selection whose
492
+ // from-edge is older than the ring's oldest loaded event would render rows the
493
+ // server still holds as empty — such a window must fetch itself. An
494
+ // untruncated ring holds everything its depth covers, so every selection
495
+ // slices it client-side with no fetch.
496
+ //
497
+ // The ring stays authoritative only while the window reaches to/past the
498
+ // NEWEST loaded ring row: every ring row then falls inside the window, and
499
+ // the ring being the server's globally-newest-ringLimit rows makes them the
500
+ // newest-ringLimit rows within the window too — a server window fetch would
501
+ // return the identical set. A window whose to-edge is BELOW the newest ring
502
+ // row excludes the freshest rows, so a server fetch spends that budget on
503
+ // older rows the truncated ring dropped — such a window must fetch itself.
504
+ // The guard is ring-relative, not wall-clock: distance from the clock proves
505
+ // nothing about coverage (under heavy churn the ring's whole budget can sit
506
+ // inside the last few minutes, leaving a just-frozen window's slice empty).
507
+ //
508
+ // Two allowances keep the LIVE sliding selection — an explicit [from,to]
509
+ // re-derived from the clock on a coarse tick — on the zero-fetch ring path:
510
+ // the newest-row edge caps at `now` (a hub clock ahead of the client stamps
511
+ // ring rows in the client's future; they must not outrun a to-edge that
512
+ // tracks the client clock), and the comparison tolerates one tick of lag
513
+ // (delta merges land fresh rows while the live to-edge waits for its next
514
+ // re-derive; without the tolerance every merge would re-key a full-window
515
+ // fetch until the tick catches up).
516
+ //
517
+ // An empty truncated ring (or one with no parseable timestamps) proves
518
+ // nothing about coverage, so it fails toward fetching. Exported for unit
519
+ // tests; not re-exported publicly.
520
+ const LIVE_WINDOW_TICK_SLACK_MS = 2 * LIVE_TICK_MS
521
+ export function needsWindowFetch(
522
+ ring: Pick<RetainedRing, 'events' | 'truncated'>,
523
+ fromMs: number,
524
+ toMs: number,
525
+ now: number,
526
+ ): boolean {
527
+ if (!ring.truncated) return false
528
+ let oldest = Number.POSITIVE_INFINITY
529
+ let newest = Number.NEGATIVE_INFINITY
530
+ for (const e of ring.events) {
531
+ const t = new Date(e.timestamp).getTime()
532
+ if (!Number.isFinite(t)) continue
533
+ if (t < oldest) oldest = t
534
+ if (t > newest) newest = t
535
+ }
536
+ if (Number.isFinite(newest) && toMs >= Math.min(newest, now) - LIVE_WINDOW_TICK_SLACK_MS) return false
537
+ return fromMs < oldest
538
+ }
442
539
 
443
- // Pin the base window when the range (or cap) changes not on every render —
444
- // so the query key is stable and react-query can cache it. Recency is the
445
- // live poll's job.
446
- const window = useMemo<TimelineRange>(() => {
447
- if (query.fromMs != null && query.toMs != null) {
448
- // An explicit window (frozen brush, or a hand-entered ?from&to) must not
449
- // reach further back than maxRangeDays the same cap rangeSpanMs applies
450
- // to the range-derived branch below. Anchor at the recent edge.
451
- const capMs = (capabilities.maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS) * DAY_MS
452
- const from = Math.max(query.fromMs, query.toMs - capMs)
453
- // Sliding: quantize so two ticks inside one step share a query key (no
454
- // refetch). The precise [from,to] is still enforced by applyClientFilters.
455
- if (query.sliding) {
456
- const q = quantizeBaseWindow(from, query.toMs)
457
- return { from: q.fromMs, to: q.toMs }
540
+ // A frozen window never slides and never polls, so it refetches only on a
541
+ // genuinely new mount past this staleness. Not cached forever: a late-ingested
542
+ // event CAN land inside an old window, so an aged cache entry may still
543
+ // refresh.
544
+ const FROZEN_WINDOW_STALE_MS = 10 * 60 * 1000
545
+
546
+ // Ring keys whose next fetch must be a full reload (manual refresh). A side
547
+ // flag, not a side cursor: consuming it only switches the PATH of the next
548
+ // fetch all sync state that must stay consistent with the data lives inside
549
+ // RetainedRing itself.
550
+ const retainedForceResync = new Set<string>()
551
+
552
+ // Ring-fetch orchestration, the retained twin of client.ts's
553
+ // runDeltaSyncFetch: full ring load when there's no cached page (or a resync
554
+ // is due), else delta pages merged in with replace-by-id. Extracted so the
555
+ // full-load → delta-poll → cursor-reset contract is exercisable without a
556
+ // React render; state (the cached page, the force flag) is passed in
557
+ // explicitly.
558
+ export async function runRetainedRingFetch(deps: {
559
+ ringKey: string
560
+ cached: RetainedRing | undefined
561
+ forceResync: Set<string>
562
+ // Retention depth of the backend. Undefined = unbounded (a local store may
563
+ // be configured to retain forever): the full load starts at epoch zero and
564
+ // delta merges never age-prune - the ring row cap is the only bound.
565
+ capMs?: number
566
+ now: number
567
+ signal?: AbortSignal
568
+ // Ring row cap: the server's per-request ceiling for this source (the hub
569
+ // serves up to 50k; the local binary pages at 10k). Also bounds delta
570
+ // accumulation client-side.
571
+ ringLimit?: number
572
+ // Scope the server query when the consumer is namespace-scoped; see
573
+ // namespacesParam.
574
+ namespaces?: string[]
575
+ // Anti-entropy cadence: how stale the ring may get before a full reload
576
+ // (server-side evictions and retention prunes are invisible to deltas).
577
+ // The local binary reloads cheaply so it keeps its historical 5-minute
578
+ // cadence; a Cloud ring is a heavy transfer and resyncs hourly.
579
+ resyncMs?: number
580
+ }): Promise<RetainedRing> {
581
+ const {
582
+ ringKey, cached, forceResync, capMs, now, signal,
583
+ ringLimit = RETAINED_RING_LIMIT, namespaces, resyncMs = RETAINED_FULL_RESYNC_MS,
584
+ } = deps
585
+ // Anti-entropy: past the resync window (or on manual refresh), fall through
586
+ // to a full reload regardless of cursor state — refreshing coverage,
587
+ // recomputing the truncated flag, and dropping anything deltas can't
588
+ // retract.
589
+ // A negative age (loadedAtMs in the future — backward clock step, suspend/
590
+ // resume) counts as due: the resync clock must fail toward refreshing, not
591
+ // toward silently suspending anti-entropy for hours.
592
+ const ringAge = cached != null ? now - cached.loadedAtMs : 0
593
+ const resyncDue = forceResync.has(ringKey) || (cached != null && (ringAge < 0 || ringAge > resyncMs))
594
+ if (cached && !resyncDue) {
595
+ if (!cached.cursor) {
596
+ return cached
597
+ }
598
+ try {
599
+ let cursor = cached.cursor
600
+ let merged = cached.events
601
+ let changed = false
602
+ for (let page = 0; page < RETAINED_DELTA_MAX_PAGES; page++) {
603
+ const delta = await fetchRetainedDelta(cursor, signal, namespaces)
604
+ if (delta.cursor) cursor = delta.cursor
605
+ if (delta.events.length) {
606
+ merged = mergeDeltaEvents(merged, delta.events, Number.POSITIVE_INFINITY)
607
+ changed = true
458
608
  }
459
- return { from, to: query.toMs }
609
+ if (!delta.more) break
460
610
  }
461
- const to = Date.now()
462
- return { from: to - rangeSpanMs(query.timeRange, capabilities.maxRangeDays), to }
463
- // eslint-disable-next-line react-hooks/exhaustive-deps
464
- }, [query.fromMs, query.toMs, query.timeRange, query.sliding, capabilities.maxRangeDays])
611
+ // Returning the cached reference on a no-op delta skips re-renders. An
612
+ // idle cluster's cursor is stable by construction — verified against the
613
+ // hub: EventsIngestedSince starts its frontier AT `since` and advances
614
+ // it only past emitted rows, and the read-side clamp can only lower a
615
+ // frontier that already rests at or below the visibility edge.
616
+ if (!changed && cursor === cached.cursor) return cached
617
+ // Two bounds keep an always-open tab from growing without limit: the
618
+ // retention floor (the server TTL-deletes past the same boundary,
619
+ // held below the depth by the skew slack so a client clock ahead of
620
+ // the hub cannot prune events the hub still retains) and the ring row
621
+ // cap — accumulation past it drops the OLDEST rows, the same
622
+ // semantics as the initial load and the local source's ring, and
623
+ // flips `truncated` so the UI says so.
624
+ const floor = capMs != null ? now - capMs - RETAINED_CLOCK_SKEW_SLACK_MS : Number.NEGATIVE_INFINITY
625
+ const pruned = capMs != null ? merged.filter((e) => new Date(e.timestamp).getTime() >= floor) : merged
626
+ const capped = pruned.length > ringLimit
627
+ return {
628
+ events: capped ? pruned.slice(0, ringLimit) : pruned,
629
+ coverage: cached.coverage,
630
+ truncated: cached.truncated || capped,
631
+ cursor,
632
+ loadedAtMs: cached.loadedAtMs,
633
+ }
634
+ } catch (err) {
635
+ // A rejected cursor (hub restarted onto an older build, operator wiped
636
+ // the store) is recoverable: fall through to a full reload. Anything
637
+ // else propagates — react-query keeps the cached ring visible.
638
+ if (!(err instanceof ApiError && err.status === 400)) {
639
+ throw err
640
+ }
641
+ }
642
+ }
643
+ // The recent edge extends past the client clock by the skew slack (a client
644
+ // clock behind the hub would otherwise leave a permanent hole: events in
645
+ // (clientNow, hubNow] are under the returned cursor, so no delta re-delivers
646
+ // them). The window slides back by the same slack to stay within the hub's
647
+ // maximum range.
648
+ const to = now + RETAINED_CLOCK_SKEW_SLACK_MS
649
+ const full = await fetchRetainedWindow(capMs != null ? to - capMs : 0, to, signal, ringLimit, namespaces)
650
+ // Consume the flag only while this fetch still owns the query. A resolved
651
+ // load can still be discarded — a manual refresh cancels the in-flight poll
652
+ // AFTER its network call finished — and deleting then would eat the flag
653
+ // that refresh just armed, turning it into a silent no-op delta poll.
654
+ if (!signal?.aborted) forceResync.delete(ringKey)
655
+ return {
656
+ events: full.events,
657
+ coverage: full.coverage,
658
+ truncated: full.truncated,
659
+ cursor: full.cursor,
660
+ loadedAtMs: now,
661
+ }
662
+ }
465
663
 
466
- const base = useQuery<RetainedWindowResult>({
467
- // apiBase scopes the key: a host that swaps clusters (mutable module global)
468
- // must not serve the previous cluster's cached window.
469
- queryKey: ['timeline-retained', getApiBase(), 'base', window.from, window.to],
470
- queryFn: ({ signal }) => fetchRetainedWindow(window.from, window.to, signal),
664
+ // One events hook for every mode. Local and hub-backed timelines share the
665
+ // whole cycle ring load, cursor deltas, client-side re-windowing and
666
+ // differ only in depth (capMs) and the server's per-request row ceiling
667
+ // (ringLimit). The endpoint path is identical (`{apiBase}/timeline/events`);
668
+ // apiBase alone decides which server answers.
669
+ function createRingEventsHook(opts: {
670
+ // Undefined = unbounded depth (the server's own retention is the bound).
671
+ capMs?: number
672
+ ringLimit: number
673
+ resyncMs: number
674
+ }): (query: TimelineQuery) => TimelineEventsResult {
675
+ const { capMs, ringLimit, resyncMs } = opts
676
+ return function useRingEvents(query: TimelineQuery): TimelineEventsResult {
677
+ const enabled = query.enabled ?? true
678
+ const queryClient = useQueryClient()
679
+
680
+ // The key carries the depth, the apiBase (a host that swaps clusters must
681
+ // not serve the previous cluster's ring), and the namespace scope (the
682
+ // ring is row-capped, so the SERVER query must be scoped for a
683
+ // namespace-scoped consumer — an all-namespace ring could spend its whole
684
+ // budget elsewhere). It does NOT carry the query's [from,to]: period
685
+ // changes re-window the loaded ring client-side in applyClientFilters,
686
+ // issuing no fetch. A frozen window the ring cannot answer runs its own
687
+ // separately-keyed query below instead of widening the ring.
688
+ const apiBase = getApiBase()
689
+ const ringNamespacesKey = query.namespaces?.length ? [...query.namespaces].sort().join(',') : ''
690
+ const queryKey = useMemo(
691
+ () => ['timeline-ring', apiBase, capMs ?? 'unbounded', ringNamespacesKey],
692
+ [apiBase, ringNamespacesKey],
693
+ )
694
+
695
+ const ring = useQuery<RetainedRing>({
696
+ queryKey,
697
+ queryFn: ({ signal }) =>
698
+ runRetainedRingFetch({
699
+ ringKey: JSON.stringify(queryKey),
700
+ cached: queryClient.getQueryData<RetainedRing>(queryKey),
701
+ forceResync: retainedForceResync,
702
+ capMs,
703
+ now: Date.now(),
704
+ signal,
705
+ ringLimit,
706
+ namespaces: ringNamespacesKey ? ringNamespacesKey.split(',') : undefined,
707
+ resyncMs,
708
+ }),
471
709
  enabled,
472
- staleTime: LIVE_REFETCH_MS,
473
- // The base window quantizes to fixed steps, so its key rotates every few
474
- // minutes even while live. Hold the previous window's events through the
475
- // refetch instead of blanking the range on each rotation.
476
- placeholderData: keepPreviousData,
710
+ refetchInterval: RETAINED_POLL_MS,
711
+ staleTime: 5000,
477
712
  })
478
713
 
479
- // Fixed recent-window re-poll. Key is stable; queryFn reads the clock fresh
480
- // each tick so the window slides without churning the cache key. Skipped for
481
- // a purely historical brush (its right edge is older than the live window),
482
- // where recency merging would only add events the time filter drops.
483
- const liveEnabled = enabled && (query.toMs == null || query.toMs >= Date.now() - LIVE_WINDOW_MS)
484
- const live = useQuery<RetainedWindowResult>({
485
- queryKey: ['timeline-retained', getApiBase(), 'live'],
714
+ // On a store holding more than ringLimit rows, an explicit frozen
715
+ // [from,to] older than the ring's oldest loaded event has NO rows in the
716
+ // ring even though the server holds them slicing would render the deep
717
+ // past as empty. Such a window fetches itself: same endpoint, same wire
718
+ // contract in both modes. Ring-covered and live-edge windows never arm
719
+ // this query (see needsWindowFetch), so period changes and the live tick
720
+ // stay zero-fetch.
721
+ //
722
+ // The clock sample only refreshes when a dep changes; that is safe here
723
+ // because the predicate is ring-relative — the clock only caps the
724
+ // newest-row comparison against skew-stamped future rows, and a stale
725
+ // sample makes that cap tighter (toward the ring), never looser.
726
+ const windowFromMs = query.fromMs
727
+ const windowToMs = query.toMs
728
+ const windowNeeded = useMemo(
729
+ () =>
730
+ enabled && windowFromMs != null && windowToMs != null && ring.data != null &&
731
+ needsWindowFetch(ring.data, windowFromMs, windowToMs, Date.now()),
732
+ [enabled, windowFromMs, windowToMs, ring.data],
733
+ )
734
+ const windowQuery = useQuery<RetainedWindowResult>({
735
+ queryKey: ['timeline-window', apiBase, windowFromMs, windowToMs, ringNamespacesKey],
486
736
  queryFn: ({ signal }) => {
487
- const to = Date.now()
488
- return fetchRetainedWindow(to - LIVE_WINDOW_MS, to, signal)
737
+ if (windowFromMs == null || windowToMs == null) {
738
+ // Unreachable: `enabled` requires both bounds. Guards the invariant
739
+ // without a non-null assertion.
740
+ throw new Error('timeline window query ran without an explicit [from,to]')
741
+ }
742
+ return fetchRetainedWindow(
743
+ windowFromMs,
744
+ windowToMs,
745
+ signal,
746
+ ringLimit,
747
+ ringNamespacesKey ? ringNamespacesKey.split(',') : undefined,
748
+ )
489
749
  },
490
- enabled: liveEnabled,
491
- refetchInterval: LIVE_REFETCH_MS,
750
+ enabled: windowNeeded,
751
+ staleTime: FROZEN_WINDOW_STALE_MS,
492
752
  })
493
753
 
494
- const merged = useMemo(
495
- () => mergeWindows(base.data, live.data),
496
- [base.data, live.data],
497
- )
498
-
499
754
  const kindsKey = query.kinds?.join(',')
755
+ const namespacesKey = query.namespaces?.join(',')
756
+ // A preset-derived window slides with the clock; on an idle ring the memo
757
+ // would never re-sample it (the no-op cached return keeps ring identity
758
+ // stable), freezing a "24h" bound overnight. A coarse tick keeps the label
759
+ // honest without meaningful churn.
760
+ const derivedWindow =
761
+ query.fromMs == null && query.toMs == null && !!query.timeRange && query.timeRange !== 'all'
762
+ // Gated on `enabled` too: a disabled consumer (an unopened tab) must not
763
+ // pay a periodic re-filter of the shared ring. The bump on arming keeps
764
+ // the window fresh across a disable→enable gap (tab reopened hours later
765
+ // must not show the stale bound until the next tick).
766
+ const tickActive = derivedWindow && enabled
767
+ const [presetTick, setPresetTick] = useState(0)
768
+ useEffect(() => {
769
+ if (!tickActive) return
770
+ setPresetTick((t) => t + 1)
771
+ const id = setInterval(() => setPresetTick((t) => t + 1), RETAINED_PRESET_TICK_MS)
772
+ return () => clearInterval(id)
773
+ }, [tickActive])
500
774
  const data = useMemo(() => {
501
- if (base.data === undefined && live.data === undefined) return undefined
502
- return applyClientFilters(merged.events, query)
775
+ if (windowNeeded) {
776
+ // Serving from the frozen-window fetch. Same client filters as the
777
+ // ring path so the two are indistinguishable to consumers; undefined
778
+ // while in flight so the host shows a loader, not an empty ring slice.
779
+ return windowQuery.data ? applyClientFilters(windowQuery.data.events, query) : undefined
780
+ }
781
+ if (!ring.data) return undefined
782
+ // A `timeRange` preset without an explicit [from,to] window resolves to
783
+ // a client-side bound over the ring — the retained twin of the local
784
+ // source's server-side `since` resolution. Without this, a consumer
785
+ // like the Applications History tab picking "24h" would silently get
786
+ // the whole ring.
787
+ let effective = query
788
+ if (derivedWindow) {
789
+ const span = rangeSpanMs(query.timeRange, capMs)
790
+ if (span != null) {
791
+ effective = { ...query, fromMs: Date.now() - (capMs != null ? Math.min(span, capMs) : span) }
792
+ }
793
+ }
794
+ return applyClientFilters(ring.data.events, effective)
795
+ // Every filter is client-side here (the ring key carries none of them),
796
+ // so each rides the memo: array-valued ones via join keys so identity
797
+ // churn from the host doesn't re-filter. The live tick advances
798
+ // query.toMs, re-windowing to the sliding edge with no refetch.
503
799
  // eslint-disable-next-line react-hooks/exhaustive-deps
504
800
  }, [
505
- merged,
506
- base.data,
507
- live.data,
508
- query.namespaces,
801
+ ring.data,
802
+ windowNeeded,
803
+ windowQuery.data,
804
+ namespacesKey,
509
805
  kindsKey,
510
806
  query.includeK8sEvents,
511
807
  query.includeDeleted,
@@ -513,20 +809,44 @@ function createRetainedEventsHook(
513
809
  query.limit,
514
810
  query.fromMs,
515
811
  query.toMs,
812
+ query.timeRange,
813
+ capMs,
814
+ derivedWindow,
815
+ presetTick,
516
816
  ])
517
817
 
818
+ if (windowNeeded) {
819
+ // Every signal follows the query that serves the data: the ring keeps
820
+ // polling in the background, but its flags describe data the consumer
821
+ // is not looking at.
822
+ return {
823
+ data,
824
+ isLoading: windowQuery.isLoading,
825
+ isFetching: windowQuery.isFetching,
826
+ isError: windowQuery.isError,
827
+ error: windowQuery.error,
828
+ refetch: () => {
829
+ windowQuery.refetch()
830
+ },
831
+ coverage: windowQuery.data?.coverage,
832
+ truncated: windowQuery.data?.truncated,
833
+ }
834
+ }
835
+
518
836
  return {
519
837
  data,
520
- isLoading: base.isLoading,
521
- isFetching: base.isFetching || live.isFetching,
522
- // Base failure is a real error; a failing live poll must not blank an
523
- // already-loaded range.
524
- isError: base.isError,
838
+ isLoading: ring.isLoading,
839
+ isFetching: ring.isFetching,
840
+ isError: ring.isError,
841
+ error: ring.error,
525
842
  refetch: () => {
526
- base.refetch()
527
- live.refetch()
843
+ // Manual refresh is the resync backstop: flag the ring so the next
844
+ // fetch reloads it whole instead of trusting accumulated state.
845
+ retainedForceResync.add(JSON.stringify(queryKey))
846
+ ring.refetch()
528
847
  },
529
- coverage: merged.coverage,
848
+ coverage: ring.data?.coverage,
849
+ truncated: ring.data?.truncated,
530
850
  }
531
851
  }
532
852
  }
@@ -564,14 +884,31 @@ async function fetchRetainedOverview(range: TimelineRange): Promise<TimelineOver
564
884
  return { buckets, availableFromMs: env.availableFromMs }
565
885
  }
566
886
 
887
+ export const localSource: TimelineSource = {
888
+ capabilities: { mode: 'local', ringLimit: LOCAL_RING_LIMIT },
889
+ useEvents: createRingEventsHook({
890
+ // No depth cap: the binary's own ring size and --timeline-retention are
891
+ // the real bounds, and retention may legitimately be unlimited. The ring
892
+ // row ceiling still bounds client-side growth.
893
+ ringLimit: LOCAL_RING_LIMIT,
894
+ resyncMs: LOCAL_FULL_RESYNC_MS,
895
+ }),
896
+ }
897
+
567
898
  export function createRetainedSource(config: TimelineSourceConfig): TimelineSource {
568
899
  const capabilities: TimelineSourceCapabilities = {
569
900
  mode: 'retained',
901
+ ringLimit: RETAINED_RING_LIMIT,
570
902
  maxRangeDays: config.maxRangeDays,
571
903
  }
572
904
  return {
573
905
  capabilities,
574
- useEvents: createRetainedEventsHook(capabilities),
906
+ useEvents: createRingEventsHook({
907
+ capMs:
908
+ Math.min(capabilities.maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS, RETAINED_MAX_DEPTH_DAYS) * DAY_MS,
909
+ ringLimit: RETAINED_RING_LIMIT,
910
+ resyncMs: RETAINED_FULL_RESYNC_MS,
911
+ }),
575
912
  fetchOverview: fetchRetainedOverview,
576
913
  }
577
914
  }