@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.
- package/README.md +7 -1
- package/package.json +11 -9
- package/src/App.tsx +221 -181
- package/src/RadarApp.tsx +91 -20
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.ts +262 -34
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +580 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +327 -26
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- package/src/components/curl/ServiceCurlButton.tsx +2 -2
- package/src/components/diagnose/AISettings.tsx +121 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/helm/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +15 -4
- package/src/components/home/HomeView.tsx +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.tsx +0 -3
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/SettingsDialog.tsx +80 -23
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +51 -10
- package/src/components/timeline/TimelineView.tsx +701 -15
- package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +319 -35
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +157 -0
- package/src/index.ts +12 -0
- package/src/types/clusterLoadState.ts +33 -0
- 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
|
|
20
|
-
import { BrowserRouter, MemoryRouter } from
|
|
21
|
-
import {
|
|
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
|
|
24
|
-
import { ThemeProvider } from
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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?:
|
|
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)
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
<
|
|
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 ===
|
|
167
|
-
return
|
|
233
|
+
if (router === "memory") {
|
|
234
|
+
return (
|
|
235
|
+
<MemoryRouter initialEntries={[initialPath || "/"]}>{inner}</MemoryRouter>
|
|
236
|
+
);
|
|
168
237
|
}
|
|
169
238
|
|
|
170
|
-
return
|
|
239
|
+
return (
|
|
240
|
+
<BrowserRouter basename={basename || undefined}>{inner}</BrowserRouter>
|
|
241
|
+
);
|
|
171
242
|
}
|
|
172
243
|
|
|
173
244
|
export default RadarApp;
|
|
@@ -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
|
+
})
|