@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,288 @@
1
+ // Client for the local AI-diagnose engine (OSS BYO-agent). The agent CLI runs
2
+ // on the user's own machine/subscription against Radar's MCP; this just starts
3
+ // the investigation and consumes its SSE event stream.
4
+ import { getApiBase, getCredentialsMode } from "./config";
5
+
6
+ export interface AgentInfo {
7
+ name: string;
8
+ label: string;
9
+ path: string;
10
+ version: string;
11
+ present: boolean;
12
+ supported: boolean;
13
+ }
14
+
15
+ export interface AgentsResponse {
16
+ agents: AgentInfo[];
17
+ enabled: boolean;
18
+ // Machine-scoped consent per disclosure surface, recorded server-side
19
+ // (~/.radar) — one acknowledgment covers the web panel and the CLI.
20
+ consented?: { standard?: boolean; cursor?: boolean };
21
+ }
22
+
23
+ export interface DiagnoseStep {
24
+ id: string;
25
+ tool: string;
26
+ status: "running" | "done";
27
+ ms?: number;
28
+ summary?: string; // input args (on running)
29
+ result?: string; // result text (on done), capped
30
+ truncated?: boolean; // result was capped — payload shown/copied is partial
31
+ }
32
+
33
+ export interface Diagnosis {
34
+ healthy?: boolean;
35
+ inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
36
+ rootCause: string;
37
+ report: string;
38
+ remediation: string[];
39
+ recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
40
+ recommendedReason?: string; // why the recommended step is the safe pick
41
+ confidence?: number;
42
+ costUsd?: number;
43
+ turns?: number;
44
+ sessionId?: string;
45
+ }
46
+
47
+ export interface HealthLine {
48
+ severity?: string;
49
+ reason?: string; // issue Reason / audit CheckID
50
+ message?: string;
51
+ }
52
+
53
+ export interface ResourceHealthSignal {
54
+ health?: string;
55
+ issueCount?: number;
56
+ highestSeverity?: string;
57
+ topReason?: string;
58
+ issues?: HealthLine[];
59
+ auditCount?: number;
60
+ auditSeverity?: string;
61
+ topFinding?: string;
62
+ auditFindings?: HealthLine[];
63
+ }
64
+
65
+ export interface DiagnoseStreamEvent {
66
+ type:
67
+ | "turn"
68
+ | "phase"
69
+ | "step"
70
+ | "thinking"
71
+ | "done"
72
+ | "error"
73
+ | "closed";
74
+ phase?: string;
75
+ step?: DiagnoseStep;
76
+ token?: string;
77
+ diagnosis?: Diagnosis;
78
+ error?: string;
79
+ question?: string; // on "turn"
80
+ apply?: boolean; // on "turn"
81
+ }
82
+
83
+ // A run is a durable, server-owned investigation. Its lifetime is independent of
84
+ // any browser tab — it survives panel close / navigation / refresh while the radar
85
+ // server runs.
86
+ export interface RunSummary {
87
+ id: string;
88
+ kind: string;
89
+ namespace: string;
90
+ name: string;
91
+ context: string;
92
+ agent?: string; // backend CLI that drove this run ("claude"/"codex")
93
+ isolated?: boolean;
94
+ model?: string;
95
+ effort?: string;
96
+ managedBy?: string; // GitOps/Helm owner of the target ("Argo CD"/"Flux"/"Helm"), for the Apply warning
97
+ health?: ResourceHealthSignal;
98
+ status: "running" | "done" | "error" | "stopped" | "stale";
99
+ sessionId?: string;
100
+ preview?: string;
101
+ createdAt: string;
102
+ updatedAt: string;
103
+ }
104
+
105
+ export async function fetchAgents(
106
+ signal?: AbortSignal,
107
+ ): Promise<AgentsResponse> {
108
+ const res = await fetch(`${getApiBase()}/agents`, {
109
+ credentials: getCredentialsMode(),
110
+ signal,
111
+ });
112
+ if (!res.ok) throw new Error(`agents: ${res.status}`);
113
+ return res.json();
114
+ }
115
+
116
+ async function errorText(res: Response): Promise<string> {
117
+ try {
118
+ const d = await res.json();
119
+ if (d && typeof d.error === "string") return d.error;
120
+ } catch {
121
+ /* ignore */
122
+ }
123
+ return `request failed (${res.status})`;
124
+ }
125
+
126
+ // DiagnoseError carries the HTTP status so callers can special-case (e.g. 409 cap).
127
+ export class DiagnoseError extends Error {
128
+ status: number;
129
+ constructor(status: number, message: string) {
130
+ super(message);
131
+ this.status = status;
132
+ }
133
+ }
134
+
135
+ const RUNS = () => `${getApiBase()}/diagnose/runs`;
136
+
137
+ // createRun starts a server-side investigation (or focuses a live one for the same
138
+ // target) and returns its run summary.
139
+ export async function createRun(
140
+ target: {
141
+ kind: string;
142
+ namespace: string;
143
+ name: string;
144
+ },
145
+ opts?: {
146
+ agent?: string;
147
+ isolated?: boolean;
148
+ model?: string;
149
+ effort?: string;
150
+ },
151
+ ): Promise<RunSummary> {
152
+ const res = await fetch(RUNS(), {
153
+ method: "POST",
154
+ credentials: getCredentialsMode(),
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ ...target, ...opts }),
157
+ });
158
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
159
+ return res.json();
160
+ }
161
+
162
+ export interface RunsResponse {
163
+ runs: RunSummary[];
164
+ // Persistence stopped working (disk error) — history will NOT survive a
165
+ // restart; the UI should say so instead of letting the user assume otherwise.
166
+ historyDegraded?: boolean;
167
+ }
168
+
169
+ // listRuns returns all server-side runs (newest first) — the source of truth for
170
+ // the recent-investigations list.
171
+ export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
172
+ const res = await fetch(RUNS(), {
173
+ credentials: getCredentialsMode(),
174
+ signal,
175
+ });
176
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
177
+ const d = await res.json();
178
+ return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
179
+ }
180
+
181
+ // recordConsent acknowledges the current disclosure for a surface, server-side.
182
+ export async function recordConsent(
183
+ surface: "standard" | "cursor",
184
+ ): Promise<void> {
185
+ const res = await fetch(`${getApiBase()}/diagnose/consent`, {
186
+ method: "POST",
187
+ credentials: getCredentialsMode(),
188
+ headers: { "Content-Type": "application/json" },
189
+ body: JSON.stringify({ surface }),
190
+ });
191
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
192
+ }
193
+
194
+ // clearHistory wipes the persisted investigation history (finished runs); live
195
+ // investigations survive.
196
+ export async function clearHistory(): Promise<void> {
197
+ const res = await fetch(`${getApiBase()}/diagnose/history/clear`, {
198
+ method: "POST",
199
+ credentials: getCredentialsMode(),
200
+ });
201
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
202
+ }
203
+
204
+ // addTurn appends a follow-up (question) or an apply turn (apply + confirmed fix).
205
+ export async function addTurn(
206
+ id: string,
207
+ body: { question?: string; apply?: boolean; fix?: string },
208
+ ): Promise<void> {
209
+ const res = await fetch(`${RUNS()}/${id}/turns`, {
210
+ method: "POST",
211
+ credentials: getCredentialsMode(),
212
+ headers: { "Content-Type": "application/json" },
213
+ body: JSON.stringify(body),
214
+ });
215
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
216
+ }
217
+
218
+ // stopRun cancels a run's in-flight agent.
219
+ export async function stopRun(id: string): Promise<void> {
220
+ await fetch(`${RUNS()}/${id}/stop`, {
221
+ method: "POST",
222
+ credentials: getCredentialsMode(),
223
+ }).catch(() => {});
224
+ }
225
+
226
+ export interface SubscribeHandlers {
227
+ onEvent: (ev: DiagnoseStreamEvent) => void;
228
+ onClosed?: () => void; // the run can no longer produce events (stale/evicted)
229
+ }
230
+
231
+ /**
232
+ * Subscribes to a run's event stream: the server replays everything (so a fresh
233
+ * tab reconstructs the whole transcript) then streams live. Closing this only
234
+ * unsubscribes — the run keeps running server-side. The EventSource auto-reconnects
235
+ * on transient errors (resuming via Last-Event-ID); a "closed" event means the run
236
+ * is gone, so we stop for good.
237
+ */
238
+ export function subscribeRun(
239
+ id: string,
240
+ handlers: SubscribeHandlers,
241
+ ): () => void {
242
+ const es = new EventSource(`${RUNS()}/${id}/stream`, {
243
+ withCredentials: getCredentialsMode() === "include",
244
+ });
245
+ let closed = false;
246
+ const close = () => {
247
+ if (closed) return;
248
+ closed = true;
249
+ es.close();
250
+ };
251
+ const dispatch = (e: MessageEvent) => {
252
+ let ev: DiagnoseStreamEvent;
253
+ try {
254
+ ev = JSON.parse(e.data);
255
+ } catch {
256
+ return;
257
+ }
258
+ if (ev.type === "closed") {
259
+ close();
260
+ handlers.onClosed?.();
261
+ return;
262
+ }
263
+ handlers.onEvent(ev);
264
+ };
265
+ for (const t of [
266
+ "turn",
267
+ "phase",
268
+ "step",
269
+ "thinking",
270
+ "done",
271
+ "error",
272
+ "closed",
273
+ ] as const) {
274
+ es.addEventListener(t, dispatch);
275
+ }
276
+ es.onerror = () => {
277
+ // A permanent failure (readyState CLOSED) means the run is gone — a 404 because
278
+ // it was evicted (retention cap) or lost on a server restart. Surface it as
279
+ // closed so the view shows a "no longer available" state instead of a silent
280
+ // blank. Transient blips stay CONNECTING and auto-reconnect (Last-Event-ID
281
+ // replays only what we missed), so leave those to EventSource.
282
+ if (es.readyState === EventSource.CLOSED) {
283
+ close();
284
+ handlers.onClosed?.();
285
+ }
286
+ };
287
+ return close;
288
+ }
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
2
+
3
+ // The retained NDJSON parser goes through apiFetch; mock the client module so a
4
+ // test can hand fetchRetainedWindow an arbitrary streamed body. ApiError is only
5
+ // hit on the non-ok branch (not exercised here) but must exist for module load.
6
+ vi.mock('./client', () => ({
7
+ apiFetch: vi.fn(),
8
+ useChanges: vi.fn(),
9
+ ApiError: class ApiError extends Error {
10
+ status: number
11
+ constructor(message: string, status: number) {
12
+ super(message)
13
+ this.status = status
14
+ }
15
+ },
16
+ }))
17
+
18
+ import { apiFetch } from './client'
19
+ import {
20
+ fetchRetainedWindow,
21
+ applyClientFilters,
22
+ localOverviewFromEvents,
23
+ LIVE_WINDOW_MS,
24
+ } from './timelineSource'
25
+ import { BASE_QUANTIZE_STEP_MS } from '@skyhook-io/k8s-ui'
26
+ import type { TimelineEvent } from '../types'
27
+
28
+ const mockApiFetch = vi.mocked(apiFetch)
29
+
30
+ // A minimal streamed Response: each string in `chunks` is delivered as one
31
+ // reader.read() so tests can split a JSON line across chunk boundaries.
32
+ function streamResponse(chunks: string[]): Response {
33
+ const encoder = new TextEncoder()
34
+ let i = 0
35
+ const body = {
36
+ getReader() {
37
+ return {
38
+ read() {
39
+ if (i < chunks.length) {
40
+ return Promise.resolve({ done: false, value: encoder.encode(chunks[i++]) })
41
+ }
42
+ return Promise.resolve({ done: true, value: undefined })
43
+ },
44
+ }
45
+ },
46
+ }
47
+ return { ok: true, body } as unknown as Response
48
+ }
49
+
50
+ const T0 = Date.parse('2024-01-01T00:00:00.000Z')
51
+
52
+ function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
53
+ return {
54
+ timestamp: new Date(T0).toISOString(),
55
+ source: 'informer',
56
+ kind: 'Pod',
57
+ namespace: 'default',
58
+ name: over.id,
59
+ eventType: 'update',
60
+ ...over,
61
+ }
62
+ }
63
+
64
+ beforeEach(() => {
65
+ mockApiFetch.mockReset()
66
+ })
67
+
68
+ describe('fetchRetainedWindow (NDJSON stream parser)', () => {
69
+ it('reassembles a JSON line split across chunk boundaries', async () => {
70
+ mockApiFetch.mockResolvedValue(
71
+ streamResponse([
72
+ '{"id":"a","kind":"Pod","name":"a","namespace":"ns","source":"informer","eventType":"update"',
73
+ ',"timestamp":"2024-01-01T00:00:00.000Z"}\n{"type":"end"}\n',
74
+ ]),
75
+ )
76
+ const { events } = await fetchRetainedWindow(0, 1000)
77
+ expect(events).toHaveLength(1)
78
+ expect(events[0].id).toBe('a')
79
+ expect(events[0].kind).toBe('Pod')
80
+ })
81
+
82
+ it('separates coverage records from events', async () => {
83
+ mockApiFetch.mockResolvedValue(
84
+ streamResponse([
85
+ '{"id":"a","kind":"Pod","name":"a","namespace":"ns","source":"informer","eventType":"update","timestamp":"2024-01-01T00:00:00.000Z"}\n',
86
+ '{"type":"coverage","eventTimeStartMs":1,"eventTimeEndMs":2}\n',
87
+ '{"type":"end"}\n',
88
+ ]),
89
+ )
90
+ const { events, coverage } = await fetchRetainedWindow(0, 1000)
91
+ expect(events).toHaveLength(1)
92
+ expect(coverage).toHaveLength(1)
93
+ })
94
+
95
+ it('throws on a truncated stream (missing terminal record)', async () => {
96
+ mockApiFetch.mockResolvedValue(
97
+ streamResponse([
98
+ '{"id":"a","kind":"Pod","name":"a","namespace":"ns","source":"informer","eventType":"update","timestamp":"2024-01-01T00:00:00.000Z"}\n',
99
+ ]),
100
+ )
101
+ await expect(fetchRetainedWindow(0, 1000)).rejects.toThrow('truncated')
102
+ })
103
+
104
+ it('throws the message carried by an error terminal record', async () => {
105
+ mockApiFetch.mockResolvedValue(
106
+ streamResponse(['{"type":"error","message":"backend exploded"}\n']),
107
+ )
108
+ await expect(fetchRetainedWindow(0, 1000)).rejects.toThrow('backend exploded')
109
+ })
110
+ })
111
+
112
+ describe('applyClientFilters', () => {
113
+ const events: TimelineEvent[] = [
114
+ ev({ id: 'p1', kind: 'Pod', namespace: 'ns-a', timestamp: new Date(T0 + 3000).toISOString() }),
115
+ ev({ id: 'd1', kind: 'Deployment', namespace: 'ns-b', timestamp: new Date(T0 + 1000).toISOString() }),
116
+ ev({ id: 'k1', kind: 'Pod', namespace: 'ns-a', source: 'k8s_event', eventType: 'Warning', timestamp: new Date(T0 + 2000).toISOString() }),
117
+ ev({ id: 'del1', kind: 'Pod', namespace: 'ns-b', eventType: 'delete', timestamp: new Date(T0 + 4000).toISOString() }),
118
+ ]
119
+
120
+ it('filters by namespace', () => {
121
+ const out = applyClientFilters(events, { namespaces: ['ns-a'] })
122
+ expect(out.map((e) => e.id).sort()).toEqual(['k1', 'p1'])
123
+ })
124
+
125
+ it('filters by kind', () => {
126
+ const out = applyClientFilters(events, { kinds: ['Deployment'] })
127
+ expect(out.map((e) => e.id)).toEqual(['d1'])
128
+ })
129
+
130
+ it('drops k8s events when includeK8sEvents is false', () => {
131
+ const out = applyClientFilters(events, { includeK8sEvents: false })
132
+ expect(out.some((e) => e.id === 'k1')).toBe(false)
133
+ })
134
+
135
+ it('drops deletes when includeDeleted is false', () => {
136
+ const out = applyClientFilters(events, { includeDeleted: false })
137
+ expect(out.some((e) => e.id === 'del1')).toBe(false)
138
+ })
139
+
140
+ it('bounds the result by [fromMs,toMs] event time', () => {
141
+ const out = applyClientFilters(events, { fromMs: T0 + 1500, toMs: T0 + 3500 })
142
+ expect(out.map((e) => e.id).sort()).toEqual(['k1', 'p1'])
143
+ })
144
+
145
+ it('sorts newest-first', () => {
146
+ const out = applyClientFilters(events, {})
147
+ expect(out.map((e) => e.id)).toEqual(['del1', 'p1', 'k1', 'd1'])
148
+ })
149
+
150
+ it('caps the result at limit (keeping the newest)', () => {
151
+ const out = applyClientFilters(events, { limit: 2 })
152
+ expect(out.map((e) => e.id)).toEqual(['del1', 'p1'])
153
+ })
154
+
155
+ // Mirrors Go's TimelineEvent.IsManaged: owned, or ReplicaSet/Pod/Event.
156
+ // Enforced client-side so retained mode (whose endpoint has no
157
+ // include_managed param) behaves exactly like local mode.
158
+ it('drops managed rows only when includeManaged is explicitly false', () => {
159
+ const mixed: TimelineEvent[] = [
160
+ ev({ id: 'dep', kind: 'Deployment', namespace: 'ns-a' }),
161
+ ev({ id: 'pod', kind: 'Pod', namespace: 'ns-a' }),
162
+ ev({ id: 'rs', kind: 'ReplicaSet', namespace: 'ns-a' }),
163
+ ev({ id: 'owned-cm', kind: 'ConfigMap', namespace: 'ns-a', owner: { kind: 'Deployment', name: 'web' } }),
164
+ ]
165
+ const filtered = applyClientFilters(mixed, { includeManaged: false })
166
+ expect(filtered.map((e) => e.id)).toEqual(['dep'])
167
+ // Default (unset) keeps machinery rows — the swimlane's child lanes need them.
168
+ expect(applyClientFilters(mixed, {})).toHaveLength(4)
169
+ })
170
+ })
171
+
172
+ describe('localOverviewFromEvents', () => {
173
+ it('buckets by the hour, floors availableFromMs, and tallies by type', () => {
174
+ const h0 = Date.parse('2024-01-01T00:10:00.000Z')
175
+ const h0b = Date.parse('2024-01-01T00:50:00.000Z')
176
+ const h1 = Date.parse('2024-01-01T01:05:00.000Z')
177
+ const res = localOverviewFromEvents([
178
+ ev({ id: 'a', eventType: 'add', timestamp: new Date(h0).toISOString() }),
179
+ ev({ id: 'b', eventType: 'update', timestamp: new Date(h0b).toISOString() }),
180
+ ev({ id: 'c', eventType: 'delete', timestamp: new Date(h1).toISOString(), namespace: 'other' }),
181
+ ])
182
+ expect(res.buckets).toHaveLength(2)
183
+ expect(res.availableFromMs).toBe(h0)
184
+ const [b0, b1] = res.buckets
185
+ expect(b0.startMs).toBe(Date.parse('2024-01-01T00:00:00.000Z'))
186
+ expect(b0.summary.total).toBe(2)
187
+ expect(b0.summary.adds).toBe(1)
188
+ expect(b0.summary.updates).toBe(1)
189
+ expect(b0.summary.namespaces).toEqual(['default'])
190
+ expect(b1.summary.deletes).toBe(1)
191
+ })
192
+
193
+ it('marks a bucket unhealthy when it holds a Warning event', () => {
194
+ const res = localOverviewFromEvents([
195
+ ev({ id: 'w', eventType: 'Warning', source: 'k8s_event', timestamp: new Date(T0 + 60_000).toISOString() }),
196
+ ])
197
+ expect(res.buckets[0].summary.warnings).toBe(1)
198
+ expect(res.buckets[0].summary.worstHealth).toBe('unhealthy')
199
+ })
200
+
201
+ it('rebuckets finer with a custom bucketSizeMs', () => {
202
+ const FIVE_MIN = 5 * 60_000
203
+ const events = [
204
+ ev({ id: 'a', timestamp: new Date(T0).toISOString() }),
205
+ ev({ id: 'b', timestamp: new Date(T0 + 10 * 60_000).toISOString() }),
206
+ ]
207
+ // Same hour → one hourly bucket, but two distinct 5-minute buckets.
208
+ expect(localOverviewFromEvents(events).buckets).toHaveLength(1)
209
+ expect(localOverviewFromEvents(events, FIVE_MIN).buckets).toHaveLength(2)
210
+ })
211
+ })
212
+
213
+ describe('cross-package live-window invariant', () => {
214
+ it('quantization step stays under the live poll window so no fetch hole opens', () => {
215
+ expect(BASE_QUANTIZE_STEP_MS).toBeLessThan(LIVE_WINDOW_MS)
216
+ })
217
+ })