@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.
- package/package.json +1 -1
- package/src/App.tsx +7 -4
- package/src/api/timelineSource.test.ts +464 -21
- package/src/api/timelineSource.ts +521 -184
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/timeline/RetainedTimelineScrubber.tsx +21 -13
- package/src/components/timeline/TimelineList.tsx +72 -49
- package/src/components/timeline/TimelineView.tsx +66 -16
- package/src/utils/auditBadges.ts +1 -1
|
@@ -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}/
|
|
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,
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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 —
|
|
126
|
-
//
|
|
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
|
-
//
|
|
130
|
-
//
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
case '
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
case '
|
|
272
|
-
|
|
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 =
|
|
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
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
609
|
+
if (!delta.more) break
|
|
460
610
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
//
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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:
|
|
491
|
-
|
|
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 (
|
|
502
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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:
|
|
521
|
-
isFetching:
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
isError: base.isError,
|
|
838
|
+
isLoading: ring.isLoading,
|
|
839
|
+
isFetching: ring.isFetching,
|
|
840
|
+
isError: ring.isError,
|
|
841
|
+
error: ring.error,
|
|
525
842
|
refetch: () => {
|
|
526
|
-
|
|
527
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|