@skyhook-io/radar-app 1.8.6 → 1.8.8

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 (109) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -10
  3. package/src/App.tsx +278 -230
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.delta.test.ts +89 -0
  7. package/src/api/client.deltaSync.test.ts +216 -0
  8. package/src/api/client.rightsizing.test.ts +32 -0
  9. package/src/api/client.ts +1462 -246
  10. package/src/api/diagnose.ts +288 -0
  11. package/src/api/timelineSource.test.ts +217 -0
  12. package/src/api/timelineSource.ts +582 -0
  13. package/src/components/ConnectionErrorView.tsx +174 -70
  14. package/src/components/ContextSwitcher.tsx +13 -5
  15. package/src/components/applications/ApplicationsView.tsx +775 -80
  16. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  17. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  18. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  19. package/src/components/cost/CostTrendChart.tsx +103 -72
  20. package/src/components/cost/CostView.test.ts +12 -0
  21. package/src/components/cost/CostView.tsx +494 -229
  22. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  23. package/src/components/cost/CostViewTabs.tsx +40 -0
  24. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  25. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  26. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  27. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  28. package/src/components/cost/cloud-console.test.ts +39 -0
  29. package/src/components/cost/cloud-console.ts +81 -0
  30. package/src/components/cost/errors.ts +8 -0
  31. package/src/components/cost/format.test.ts +27 -0
  32. package/src/components/cost/format.ts +46 -0
  33. package/src/components/cost/kinds.ts +5 -0
  34. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  35. package/src/components/diagnose/AISettings.tsx +116 -0
  36. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  37. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  38. package/src/components/diagnose/Home.tsx +163 -0
  39. package/src/components/diagnose/InvestigationView.tsx +604 -0
  40. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  41. package/src/components/diagnose/launch.ts +65 -0
  42. package/src/components/diagnose/parts.tsx +1689 -0
  43. package/src/components/dock/BottomDock.tsx +2 -3
  44. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  45. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  46. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  47. package/src/components/execution/batch-run-actions.test.ts +48 -0
  48. package/src/components/execution/batch-run-actions.ts +24 -0
  49. package/src/components/execution/batch-timeline.test.ts +57 -0
  50. package/src/components/execution/batch-timeline.ts +46 -0
  51. package/src/components/execution/execution-definition.test.ts +208 -0
  52. package/src/components/execution/execution-definition.ts +245 -0
  53. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  54. package/src/components/gitops/GitOpsView.tsx +81 -14
  55. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  56. package/src/components/helm/ChartBrowser.tsx +2 -3
  57. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  58. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  59. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  60. package/src/components/helm/HelmView.tsx +2 -3
  61. package/src/components/helm/InstallWizard.tsx +3 -5
  62. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  63. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  64. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  65. package/src/components/home/CostCard.tsx +21 -36
  66. package/src/components/home/HomeView.tsx +17 -15
  67. package/src/components/home/MCPSetupDialog.tsx +1 -1
  68. package/src/components/home/mcpToolCatalog.ts +2 -2
  69. package/src/components/issues/IssuesPane.tsx +9 -1
  70. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  71. package/src/components/portforward/PortForwardButton.tsx +2 -2
  72. package/src/components/portforward/PortForwardManager.tsx +19 -14
  73. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  74. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  75. package/src/components/resource/RightsizingStrip.tsx +320 -127
  76. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  77. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  78. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  79. package/src/components/resources/ResourcesView.tsx +13 -4
  80. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/index.ts +1 -0
  82. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  83. package/src/components/rightsizing/copy.test.ts +56 -0
  84. package/src/components/rightsizing/model.test.ts +227 -0
  85. package/src/components/rightsizing/model.ts +158 -0
  86. package/src/components/rightsizing/presentation.test.ts +104 -0
  87. package/src/components/rightsizing/presentation.ts +94 -0
  88. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  89. package/src/components/settings/SettingsDialog.tsx +1290 -283
  90. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  91. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  92. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  93. package/src/components/timeline/TimelineList.tsx +81 -13
  94. package/src/components/timeline/TimelineView.tsx +842 -26
  95. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  96. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  97. package/src/components/traffic/TrafficView.tsx +8 -4
  98. package/src/components/ui/CommandPalette.tsx +2 -2
  99. package/src/components/workload/WorkloadView.tsx +912 -245
  100. package/src/context/ConnectionContext.tsx +109 -11
  101. package/src/context/DiagnoseCustomization.tsx +42 -0
  102. package/src/context/TimelineSource.tsx +50 -0
  103. package/src/hooks/useClusterLoadState.ts +73 -0
  104. package/src/hooks/useEventSource.ts +6 -0
  105. package/src/index.css +162 -1
  106. package/src/index.ts +12 -0
  107. package/src/main.tsx +1 -1
  108. package/src/types/clusterLoadState.ts +33 -0
  109. package/src/utils/navigation.ts +10 -0
package/src/RadarApp.tsx CHANGED
@@ -16,23 +16,39 @@
16
16
  // Both are applied before any children render so downstream code that
17
17
  // reads config synchronously (e.g. URL construction inside fetchJSON)
18
18
  // sees the host's values.
19
- import React from 'react';
20
- import { BrowserRouter, MemoryRouter } from 'react-router-dom';
21
- import { QueryClient, QueryClientProvider, MutationCache, QueryCache } from '@tanstack/react-query';
19
+ import React from "react";
20
+ import { BrowserRouter, MemoryRouter } from "react-router-dom";
21
+ import {
22
+ QueryClient,
23
+ QueryClientProvider,
24
+ MutationCache,
25
+ QueryCache,
26
+ } from "@tanstack/react-query";
22
27
 
23
- import App from './App';
24
- import { ThemeProvider } from './context/ThemeContext';
25
- import { ToastProvider, showApiError, showApiSuccess } from './components/ui/Toast';
26
- import { setApiBase, setBasename } from './api/config';
27
- import { NavCustomizationProvider } from './context/NavCustomization';
28
- import { FilterLocationBridge } from './filter/FilterLocationBridge';
29
- import type { NavCustomization } from './context/NavCustomization';
28
+ import App from "./App";
29
+ import { ThemeProvider } from "./context/ThemeContext";
30
+ import {
31
+ ToastProvider,
32
+ showApiError,
33
+ showApiSuccess,
34
+ } from "./components/ui/Toast";
35
+ import { setApiBase, setBasename } from "./api/config";
36
+ import { NavCustomizationProvider } from "./context/NavCustomization";
37
+ import { FilterLocationBridge } from "./filter/FilterLocationBridge";
38
+ import type { NavCustomization } from "./context/NavCustomization";
39
+ import type { ClusterLoadState } from "./types/clusterLoadState";
40
+ import { TimelineSourceProvider } from "./context/TimelineSource";
41
+ import type { TimelineSourceConfig } from "./api/timelineSource";
42
+ import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
43
+ import type { RenderDiagnoseAction } from "./context/DiagnoseCustomization";
44
+ import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
45
+ import { DiagnoseProvider } from "./components/diagnose/DiagnoseContext";
30
46
 
31
47
  // Declare the shape of mutation meta here — inlined rather than in a
32
48
  // separate side-effect-only module so consumers that tree-shake aggressively
33
49
  // (package.json sets sideEffects: ["*.css"]) can't drop the augmentation.
34
50
  // Any consumer that imports RadarApp will pull in this declaration.
35
- declare module '@tanstack/react-query' {
51
+ declare module "@tanstack/react-query" {
36
52
  interface Register {
37
53
  mutationMeta: {
38
54
  errorMessage?: string;
@@ -57,7 +73,7 @@ export interface RadarAppProps {
57
73
  * Escape hatch for tests and for host apps that can't restructure
58
74
  * around a single top-level BrowserRouter.
59
75
  */
60
- router?: 'browser' | 'memory';
76
+ router?: "browser" | "memory";
61
77
  /**
62
78
  * Optional QueryClient override. When consuming Radar inside another app
63
79
  * that already has a QueryClientProvider higher in the tree, you may
@@ -86,6 +102,14 @@ export interface RadarAppProps {
86
102
  * Defaults to `' · Radar'`.
87
103
  */
88
104
  documentTitleSuffix?: string;
105
+ /**
106
+ * Injects a resource-level "Diagnose" action (e.g. a "Diagnose with AI"
107
+ * button) into every resource detail action bar's right-aligned universal
108
+ * actions. The host returns the node to render given the resource context.
109
+ * Standalone Radar omits this and renders no Diagnose button — OSS stays
110
+ * agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
111
+ */
112
+ renderDiagnoseAction?: RenderDiagnoseAction;
89
113
  /**
90
114
  * Initial route for `router: 'memory'` (ignored for 'browser'). Lets a host
91
115
  * deep-link a specific view (e.g. '/topology') without owning the URL bar —
@@ -93,6 +117,26 @@ export interface RadarAppProps {
93
117
  * chromeless under the host's own chrome (Radar Hub's per-cluster destinations).
94
118
  */
95
119
  initialPath?: string;
120
+ /**
121
+ * Reports cluster-data warmup after the main connection is usable. Embedders
122
+ * with their own chrome (Radar Hub) can render this in their topbar while
123
+ * Radar runs with `navSlots.chrome: 'none'`.
124
+ */
125
+ onClusterLoadStateChange?: (state: ClusterLoadState) => void;
126
+ /**
127
+ * Selects the store backing the event timeline. Omit for the local event
128
+ * store the Radar binary keeps (default, standalone behavior). Set
129
+ * `{ mode: 'retained' }` when embedding behind a proxy that serves a
130
+ * longer-horizon history at `{apiBase}/timeline/events` +
131
+ * `{apiBase}/timeline/overview`; `maxRangeDays` caps how far back the
132
+ * 'all' range reaches. Generic extension point — the backend that answers
133
+ * the retained endpoints is the host's concern.
134
+ *
135
+ * Changing `mode` between renders remounts the timeline view (the local and
136
+ * retained sources expose different `useEvents` hooks; remounting avoids a
137
+ * React hook-order violation). Set it once at mount when possible.
138
+ */
139
+ timelineSource?: TimelineSourceConfig;
96
140
  }
97
141
 
98
142
  // Default QueryClient with the same shape Radar's standalone binary uses.
@@ -113,13 +157,18 @@ function makeDefaultQueryClient(): QueryClient {
113
157
  },
114
158
  onSuccess: (_data, _variables, _context, mutation) => {
115
159
  const message = mutation.options.meta?.successMessage;
116
- if (message) showApiSuccess(message, mutation.options.meta?.successDetail);
160
+ if (message)
161
+ showApiSuccess(message, mutation.options.meta?.successDetail);
117
162
  },
118
163
  }),
119
164
  queryCache: new QueryCache({
120
165
  onError: (error, query) => {
121
166
  if (query.state.data !== undefined) {
122
- console.warn('[Background sync failed]', query.queryKey, (error as Error).message);
167
+ console.warn(
168
+ "[Background sync failed]",
169
+ query.queryKey,
170
+ (error as Error).message,
171
+ );
123
172
  }
124
173
  },
125
174
  }),
@@ -129,12 +178,15 @@ function makeDefaultQueryClient(): QueryClient {
129
178
  export function RadarApp({
130
179
  apiBase,
131
180
  basename,
132
- router = 'browser',
181
+ router = "browser",
133
182
  queryClient,
134
183
  navSlots,
135
184
  manageDocumentTitle = false,
136
185
  documentTitleSuffix,
186
+ renderDiagnoseAction,
137
187
  initialPath,
188
+ onClusterLoadStateChange,
189
+ timelineSource,
138
190
  }: RadarAppProps): React.ReactElement {
139
191
  // Apply runtime config during render so module-level singletons are set
140
192
  // before children construct URLs. getApiBase() / getAuthHeaders() /
@@ -147,7 +199,10 @@ export function RadarApp({
147
199
 
148
200
  // Memo so we don't recreate the QueryClient on every render when the
149
201
  // consumer didn't pass one.
150
- const client = React.useMemo(() => queryClient ?? makeDefaultQueryClient(), [queryClient]);
202
+ const client = React.useMemo(
203
+ () => queryClient ?? makeDefaultQueryClient(),
204
+ [queryClient],
205
+ );
151
206
 
152
207
  const inner = (
153
208
  <ThemeProvider>
@@ -155,7 +210,19 @@ export function RadarApp({
155
210
  <ToastProvider>
156
211
  <NavCustomizationProvider value={navSlots}>
157
212
  <FilterLocationBridge>
158
- <App manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
213
+ <TimelineSourceProvider config={timelineSource}>
214
+ <DiagnoseCustomizationProvider
215
+ value={renderDiagnoseAction ?? defaultDiagnoseAction}
216
+ >
217
+ <DiagnoseProvider>
218
+ <App
219
+ manageDocumentTitle={manageDocumentTitle}
220
+ documentTitleSuffix={documentTitleSuffix}
221
+ onClusterLoadStateChange={onClusterLoadStateChange}
222
+ />
223
+ </DiagnoseProvider>
224
+ </DiagnoseCustomizationProvider>
225
+ </TimelineSourceProvider>
159
226
  </FilterLocationBridge>
160
227
  </NavCustomizationProvider>
161
228
  </ToastProvider>
@@ -163,11 +230,15 @@ export function RadarApp({
163
230
  </ThemeProvider>
164
231
  );
165
232
 
166
- if (router === 'memory') {
167
- return <MemoryRouter initialEntries={[initialPath || '/']}>{inner}</MemoryRouter>;
233
+ if (router === "memory") {
234
+ return (
235
+ <MemoryRouter initialEntries={[initialPath || "/"]}>{inner}</MemoryRouter>
236
+ );
168
237
  }
169
238
 
170
- return <BrowserRouter basename={basename || undefined}>{inner}</BrowserRouter>;
239
+ return (
240
+ <BrowserRouter basename={basename || undefined}>{inner}</BrowserRouter>
241
+ );
171
242
  }
172
243
 
173
244
  export default RadarApp;
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildArgoResourceSyncVars } from './client'
3
+
4
+ const options = {
5
+ revision: 'should-not-survive',
6
+ prune: true,
7
+ dryRun: false,
8
+ force: true,
9
+ applyOnly: true,
10
+ syncOptions: ['ServerSideApply=true'],
11
+ }
12
+
13
+ describe('buildArgoResourceSyncVars', () => {
14
+ it('preserves the complete Argo status ref and forces resource-safe options', () => {
15
+ expect(
16
+ buildArgoResourceSyncVars(
17
+ 'argocd',
18
+ 'guestbook',
19
+ {
20
+ group: 'apps',
21
+ kind: 'Deployment',
22
+ namespace: 'guestbook',
23
+ name: 'guestbook-ui',
24
+ },
25
+ options,
26
+ ),
27
+ ).toEqual({
28
+ namespace: 'argocd',
29
+ name: 'guestbook',
30
+ resources: [
31
+ {
32
+ group: 'apps',
33
+ kind: 'Deployment',
34
+ namespace: 'guestbook',
35
+ name: 'guestbook-ui',
36
+ },
37
+ ],
38
+ revision: undefined,
39
+ prune: false,
40
+ dryRun: false,
41
+ force: true,
42
+ applyOnly: false,
43
+ syncOptions: ['ServerSideApply=true'],
44
+ })
45
+ })
46
+
47
+ it('preserves an empty core API group', () => {
48
+ const variables = buildArgoResourceSyncVars(
49
+ 'argocd',
50
+ 'guestbook',
51
+ {
52
+ group: '',
53
+ kind: 'Service',
54
+ namespace: 'guestbook',
55
+ name: 'guestbook-ui',
56
+ },
57
+ options,
58
+ )
59
+
60
+ expect(variables.resources).toEqual([
61
+ {
62
+ group: '',
63
+ kind: 'Service',
64
+ namespace: 'guestbook',
65
+ name: 'guestbook-ui',
66
+ },
67
+ ])
68
+ })
69
+ })
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { deltaFetchCursor, maxEventSeq, mergeDeltaEvents, type ChangesDeltaMeta } from './client'
3
+ import type { TimelineEvent } from '@skyhook-io/k8s-ui'
4
+
5
+ const mk = (id: string, seq: number, tsOffsetMs: number): TimelineEvent => ({
6
+ id,
7
+ seq,
8
+ timestamp: new Date(1_700_000_000_000 + tsOffsetMs).toISOString(),
9
+ source: 'informer',
10
+ kind: 'Pod',
11
+ namespace: 'default',
12
+ name: id,
13
+ eventType: 'update',
14
+ })
15
+
16
+ describe('maxEventSeq (delta cursor)', () => {
17
+ it('returns the highest arrival number', () => {
18
+ expect(maxEventSeq([mk('a', 3, 0), mk('b', 7, 1000), mk('c', 5, 2000)])).toBe(7)
19
+ })
20
+
21
+ it('returns 0 for an empty page or seq-less events', () => {
22
+ expect(maxEventSeq([])).toBe(0)
23
+ expect(maxEventSeq([{ ...mk('a', 0, 0), seq: undefined }])).toBe(0)
24
+ })
25
+ })
26
+
27
+ describe('mergeDeltaEvents (delta page into cached page)', () => {
28
+ it('returns the cached reference untouched for an empty delta', () => {
29
+ const prev = [mk('a', 1, 0)]
30
+ expect(mergeDeltaEvents(prev, [], 100)).toBe(prev)
31
+ })
32
+
33
+ it('adds new arrivals in newest-first order', () => {
34
+ const prev = [mk('b', 2, 1000), mk('a', 1, 0)]
35
+ const merged = mergeDeltaEvents(prev, [mk('c', 3, 2000)], 100)
36
+ expect(merged.map((e) => e.id)).toEqual(['c', 'b', 'a'])
37
+ })
38
+
39
+ it('replaces a cached row when the same id re-arrives (K8s Event count bump)', () => {
40
+ const prev = [{ ...mk('bump', 1, 0), count: 1 }, mk('a', 2, 500)]
41
+ const merged = mergeDeltaEvents(prev, [{ ...mk('bump', 3, 1000), count: 5 }], 100)
42
+ expect(merged).toHaveLength(2)
43
+ expect(merged[0].id).toBe('bump')
44
+ expect(merged[0].count).toBe(5)
45
+ })
46
+
47
+ it('orders a late arrival by its timestamp, not its arrival number', () => {
48
+ const prev = [mk('b', 2, 2000), mk('a', 1, 1000)]
49
+ const merged = mergeDeltaEvents(prev, [mk('late', 3, 0)], 100)
50
+ expect(merged.map((e) => e.id)).toEqual(['b', 'a', 'late'])
51
+ })
52
+
53
+ it('caps the merged page by dropping the oldest', () => {
54
+ const prev = [mk('b', 2, 2000), mk('a', 1, 1000)]
55
+ const merged = mergeDeltaEvents(prev, [mk('c', 3, 3000)], 2)
56
+ expect(merged.map((e) => e.id)).toEqual(['c', 'b'])
57
+ })
58
+ })
59
+
60
+ describe('deltaFetchCursor (cursor selection incl. high-water)', () => {
61
+ const NOW = 1_700_000_000_000
62
+ const meta = (over: Partial<ChangesDeltaMeta> = {}): ChangesDeltaMeta => ({
63
+ epoch: 'e1',
64
+ lastFullMs: NOW - 10_000,
65
+ highWaterSeq: 0,
66
+ ...over,
67
+ })
68
+
69
+ it('requires an epoch-stamped prior load and a cached page', () => {
70
+ expect(deltaFetchCursor(undefined, [mk('a', 1, 0)], NOW)).toBe(0)
71
+ expect(deltaFetchCursor(meta({ epoch: '' }), [mk('a', 1, 0)], NOW)).toBe(0)
72
+ expect(deltaFetchCursor(meta(), undefined, NOW)).toBe(0)
73
+ })
74
+
75
+ it('forces a full resync once the anti-entropy interval elapses', () => {
76
+ expect(deltaFetchCursor(meta({ lastFullMs: NOW - 6 * 60_000 }), [mk('a', 1, 0)], NOW)).toBe(0)
77
+ })
78
+
79
+ it('uses the cached max seq in the ordinary case', () => {
80
+ expect(deltaFetchCursor(meta(), [mk('a', 7, 0), mk('b', 3, 1000)], NOW)).toBe(7)
81
+ })
82
+
83
+ it('does not regress when a capped-out delta event held the highest seq', () => {
84
+ // The seq-9 event was merged then dropped by the cap (oldest timestamp);
85
+ // cached rows top out at 7. The cursor must hold at the high water or the
86
+ // same delta gets re-downloaded on every refetch.
87
+ expect(deltaFetchCursor(meta({ highWaterSeq: 9 }), [mk('a', 7, 0)], NOW)).toBe(9)
88
+ })
89
+ })
@@ -0,0 +1,216 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { runDeltaSyncFetch, type ChangesDeltaMeta } from './client'
3
+ import type { TimelineEvent } from '@skyhook-io/k8s-ui'
4
+
5
+ // Orchestration test for the useChanges delta contract. The pure helpers
6
+ // (deltaFetchCursor / mergeDeltaEvents / maxEventSeq) are covered in
7
+ // client.delta.test.ts; here we drive the full runDeltaSyncFetch loop through
8
+ // the real fetch boundary so the header parsing, cursor priming, epoch-resync,
9
+ // and merge all participate.
10
+
11
+ const BASE_TS = 1_700_000_000_000
12
+ const mk = (id: string, seq: number, tsOffsetMs: number, extra: Partial<TimelineEvent> = {}): TimelineEvent => ({
13
+ id,
14
+ seq,
15
+ timestamp: new Date(BASE_TS + tsOffsetMs).toISOString(),
16
+ source: 'informer',
17
+ kind: 'Pod',
18
+ namespace: 'default',
19
+ name: id,
20
+ eventType: 'update',
21
+ ...extra,
22
+ })
23
+
24
+ // A page as the server returns it: an events body plus the two frontier headers
25
+ // that fetchChangesPage reads to derive epoch + maxSeq.
26
+ interface Page {
27
+ events: TimelineEvent[]
28
+ epoch: string
29
+ maxSeq: number
30
+ }
31
+ function pageResponse(page: Page): Response {
32
+ return new Response(JSON.stringify(page.events), {
33
+ status: 200,
34
+ headers: {
35
+ 'X-Radar-Timeline-Epoch': page.epoch,
36
+ 'X-Radar-Timeline-Max-Seq': String(page.maxSeq),
37
+ },
38
+ })
39
+ }
40
+
41
+ // The `since_seq` a fetched URL carries, or null for a full fetch.
42
+ function sinceSeqOf(url: string): number | null {
43
+ const m = url.match(/[?&]since_seq=(\d+)/)
44
+ return m ? Number(m[1]) : null
45
+ }
46
+
47
+ let fetchedUrls: string[]
48
+ let responder: (url: string) => Page
49
+
50
+ beforeEach(() => {
51
+ fetchedUrls = []
52
+ vi.stubGlobal('fetch', (input: RequestInfo | URL) => {
53
+ const url = String(input)
54
+ fetchedUrls.push(url)
55
+ return Promise.resolve(pageResponse(responder(url)))
56
+ })
57
+ })
58
+
59
+ afterEach(() => {
60
+ vi.unstubAllGlobals()
61
+ })
62
+
63
+ const PATH = '/changes?limit=200&filter=all'
64
+ const QUERY_STRING = 'limit=200&filter=all'
65
+ const LIMIT = 200
66
+ const META_KEY = 'changes-key'
67
+
68
+ // A single query's delta loop: fresh meta store per test, cached page threaded
69
+ // forward across polls, an explicit `now` so the anti-entropy full-resync timer
70
+ // stays out of the way unless a test wants it.
71
+ function run(metaStore: Map<string, ChangesDeltaMeta>, cached: TimelineEvent[] | undefined, now: number) {
72
+ return runDeltaSyncFetch({ path: PATH, queryString: QUERY_STRING, limit: LIMIT, metaKey: META_KEY, cached, metaStore, now })
73
+ }
74
+
75
+ const lastUrl = () => fetchedUrls[fetchedUrls.length - 1]
76
+
77
+ describe('runDeltaSyncFetch (useChanges delta orchestration)', () => {
78
+ it('first fetch (no cursor) is a full fetch; the cursor is primed from the max-seq header', async () => {
79
+ const metaStore = new Map<string, ChangesDeltaMeta>()
80
+ // Header frontier 8 exceeds the highest visible event seq (5): rows dropped
81
+ // by the server's RBAC filter still advance the cursor.
82
+ responder = () => ({ events: [mk('a', 5, 2000), mk('b', 3, 1000)], epoch: 'e1', maxSeq: 8 })
83
+
84
+ const result = await run(metaStore, undefined, 1_000)
85
+
86
+ expect(fetchedUrls).toHaveLength(1)
87
+ expect(sinceSeqOf(fetchedUrls[0])).toBeNull()
88
+ expect(result.map((e) => e.id)).toEqual(['a', 'b'])
89
+
90
+ const meta = metaStore.get(META_KEY)!
91
+ expect(meta.epoch).toBe('e1')
92
+ expect(meta.highWaterSeq).toBe(8)
93
+ expect(meta.lastFullMs).toBe(1_000)
94
+ })
95
+
96
+ it('a subsequent poll sends since_seq=<cursor>, merges delta rows by id, and advances the cursor', async () => {
97
+ const metaStore = new Map<string, ChangesDeltaMeta>()
98
+ responder = () => ({ events: [mk('a', 5, 2000), mk('b', 3, 1000)], epoch: 'e1', maxSeq: 5 })
99
+ const first = await run(metaStore, undefined, 1_000)
100
+
101
+ // A new arrival (c) plus a re-arrival of 'a' under a higher seq (count bump).
102
+ responder = () => ({ events: [mk('c', 7, 3000), mk('a', 6, 2000, { count: 9 })], epoch: 'e1', maxSeq: 7 })
103
+ const second = await run(metaStore, first, 2_000)
104
+
105
+ expect(sinceSeqOf(lastUrl())).toBe(5)
106
+ expect(second.map((e) => e.id)).toEqual(['c', 'a', 'b'])
107
+ expect(second.find((e) => e.id === 'a')!.count).toBe(9)
108
+ expect(metaStore.get(META_KEY)!.highWaterSeq).toBe(7)
109
+ })
110
+
111
+ it('an empty delta carrying a max-seq header still advances the cursor', async () => {
112
+ const metaStore = new Map<string, ChangesDeltaMeta>()
113
+ responder = () => ({ events: [mk('a', 5, 2000)], epoch: 'e1', maxSeq: 5 })
114
+ const first = await run(metaStore, undefined, 1_000)
115
+
116
+ // The entire page past seq 5 was RBAC-filtered to nothing, but the server
117
+ // reports the frontier it scanned to.
118
+ responder = () => ({ events: [], epoch: 'e1', maxSeq: 12 })
119
+ const second = await run(metaStore, first, 2_000)
120
+ expect(metaStore.get(META_KEY)!.highWaterSeq).toBe(12)
121
+
122
+ // The next poll must ride the advanced cursor, not re-request from 5.
123
+ responder = () => ({ events: [], epoch: 'e1', maxSeq: 12 })
124
+ await run(metaStore, second, 3_000)
125
+ expect(sinceSeqOf(lastUrl())).toBe(12)
126
+ })
127
+
128
+ it('an empty delta returns the cached array reference (no needless re-render)', async () => {
129
+ const metaStore = new Map<string, ChangesDeltaMeta>()
130
+ responder = () => ({ events: [mk('a', 5, 0)], epoch: 'e1', maxSeq: 5 })
131
+ const first = await run(metaStore, undefined, 1_000)
132
+
133
+ responder = () => ({ events: [], epoch: 'e1', maxSeq: 5 })
134
+ const second = await run(metaStore, first, 2_000)
135
+ expect(second).toBe(first)
136
+ })
137
+
138
+ it('an epoch change between polls forces a full resync: cursor reset, list replaced', async () => {
139
+ const metaStore = new Map<string, ChangesDeltaMeta>()
140
+ responder = () => ({ events: [mk('old', 9, 0)], epoch: 'e1', maxSeq: 9 })
141
+ const first = await run(metaStore, undefined, 1_000)
142
+
143
+ // The store restarted — seq numbering reset low, so a since_seq=9 delta comes
144
+ // back EMPTY under a NEW epoch. Trusting that empty delta as "nothing new"
145
+ // would strand the caller on the previous store's rows; the epoch mismatch
146
+ // must instead trigger a full resync.
147
+ const restarted = [mk('fresh', 2, 500)]
148
+ responder = (url) =>
149
+ sinceSeqOf(url) != null
150
+ ? { events: [], epoch: 'e2', maxSeq: 2 }
151
+ : { events: restarted, epoch: 'e2', maxSeq: 2 }
152
+ const second = await run(metaStore, first, 2_000)
153
+
154
+ expect(second).not.toBe(first)
155
+ expect(second.map((e) => e.id)).toEqual(['fresh'])
156
+ // Two fetches: the epoch-mismatched delta probe, then the full resync.
157
+ expect(sinceSeqOf(fetchedUrls[fetchedUrls.length - 2])).toBe(9)
158
+ expect(sinceSeqOf(lastUrl())).toBeNull()
159
+
160
+ const meta = metaStore.get(META_KEY)!
161
+ expect(meta.epoch).toBe('e2')
162
+ expect(meta.highWaterSeq).toBe(2)
163
+ expect(meta.lastFullMs).toBe(2_000)
164
+ })
165
+
166
+ it('equivalence: full → delta → delta yields the same set as a fresh full fetch of the final state', async () => {
167
+ // A minimal append/replace server keyed by id with monotonic seq. Each fetch
168
+ // serializes a snapshot, so pages the loop keeps are decoupled from later
169
+ // mutations of the log.
170
+ const epoch = 'e1'
171
+ const log: TimelineEvent[] = []
172
+ let nextSeq = 1
173
+ const upsert = (id: string, tsOffsetMs: number, content: number) => {
174
+ const seq = nextSeq++
175
+ const existing = log.find((e) => e.id === id)
176
+ if (existing) {
177
+ existing.seq = seq
178
+ existing.count = content
179
+ } else {
180
+ log.push(mk(id, seq, tsOffsetMs, { count: content }))
181
+ }
182
+ }
183
+ const newestFirst = (rows: TimelineEvent[]) =>
184
+ [...rows].sort((a, b) => {
185
+ const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
186
+ return byTime !== 0 ? byTime : (b.seq ?? 0) - (a.seq ?? 0)
187
+ })
188
+ responder = (url) => {
189
+ const since = sinceSeqOf(url)
190
+ const rows = since == null ? log : log.filter((e) => (e.seq ?? 0) > since)
191
+ const maxSeq = log.reduce((m, e) => Math.max(m, e.seq ?? 0), 0)
192
+ return { events: newestFirst(rows), epoch, maxSeq }
193
+ }
194
+
195
+ const metaStore = new Map<string, ChangesDeltaMeta>()
196
+ upsert('a', 1000, 1)
197
+ upsert('b', 2000, 1)
198
+ upsert('c', 3000, 1)
199
+ const l1 = await run(metaStore, undefined, 1_000)
200
+
201
+ upsert('d', 4000, 1)
202
+ upsert('a', 1000, 2) // a count bump re-arrives under the same id
203
+ const l2 = await run(metaStore, l1, 2_000)
204
+
205
+ upsert('e', 5000, 1)
206
+ upsert('b', 2000, 2)
207
+ const l3 = await run(metaStore, l2, 3_000)
208
+
209
+ // What a fresh client would get from a single full fetch of the final state.
210
+ const fresh = newestFirst(log)
211
+ const asSet = (rows: TimelineEvent[]) => rows.map((e) => `${e.id}:${e.count}`).sort()
212
+ expect(asSet(l3)).toEqual(asSet(fresh))
213
+ // No dropped or duplicated ids after two incremental merges.
214
+ expect(l3).toHaveLength(fresh.length)
215
+ })
216
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { skipToken } from '@tanstack/react-query'
3
+ import { getRightsizingScanCacheConfig } from './client'
4
+
5
+ describe('rightsizing scan cache config', () => {
6
+ it('is manual-only and retained briefly', () => {
7
+ const config = getRightsizingScanCacheConfig(['staging', 'default'], 'cluster-a')
8
+
9
+ expect(config.queryFn).toBe(skipToken)
10
+ expect(config.gcTime).toBe(5 * 60 * 1000)
11
+ })
12
+
13
+ it('normalizes namespace order and isolates cluster and namespace scopes', () => {
14
+ const config = getRightsizingScanCacheConfig(['staging', 'default'], 'cluster-a')
15
+
16
+ expect(config.namespaceKey).toBe('default,staging')
17
+ expect(config.queryKey).toEqual([
18
+ 'prometheus-rightsizing-scan',
19
+ 'cluster-a',
20
+ 'default,staging',
21
+ ])
22
+ expect(getRightsizingScanCacheConfig(['default', 'staging'], 'cluster-a').queryKey).toEqual(
23
+ config.queryKey,
24
+ )
25
+ expect(getRightsizingScanCacheConfig(['default'], 'cluster-a').queryKey).not.toEqual(
26
+ config.queryKey,
27
+ )
28
+ expect(getRightsizingScanCacheConfig(['staging', 'default'], 'cluster-b').queryKey).not.toEqual(
29
+ config.queryKey,
30
+ )
31
+ })
32
+ })