@skyhook-io/radar-app 0.2.2 → 0.3.1

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 (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
package/src/RadarApp.tsx CHANGED
@@ -16,22 +16,42 @@
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 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 {
44
+ RenderDiagnoseAction,
45
+ DiagnoseConsentCopy,
46
+ } from "./context/DiagnoseCustomization";
47
+ import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
48
+ import { DiagnoseProvider } from "./components/diagnose/DiagnoseContext";
29
49
 
30
50
  // Declare the shape of mutation meta here — inlined rather than in a
31
51
  // separate side-effect-only module so consumers that tree-shake aggressively
32
52
  // (package.json sets sideEffects: ["*.css"]) can't drop the augmentation.
33
53
  // Any consumer that imports RadarApp will pull in this declaration.
34
- declare module '@tanstack/react-query' {
54
+ declare module "@tanstack/react-query" {
35
55
  interface Register {
36
56
  mutationMeta: {
37
57
  errorMessage?: string;
@@ -56,7 +76,7 @@ export interface RadarAppProps {
56
76
  * Escape hatch for tests and for host apps that can't restructure
57
77
  * around a single top-level BrowserRouter.
58
78
  */
59
- router?: 'browser' | 'memory';
79
+ router?: "browser" | "memory";
60
80
  /**
61
81
  * Optional QueryClient override. When consuming Radar inside another app
62
82
  * that already has a QueryClientProvider higher in the tree, you may
@@ -70,6 +90,65 @@ export interface RadarAppProps {
70
90
  * See ./context/NavCustomization for the slot shape.
71
91
  */
72
92
  navSlots?: NavCustomization;
93
+ /**
94
+ * Whether Radar may set the browser tab title (`document.title`) per view.
95
+ * Defaults to OFF: embedders keep title ownership without opting out. The
96
+ * standalone binary opts in (`web/src/main.tsx` renders
97
+ * `<RadarApp manageDocumentTitle />`), and any full-page embed that wants
98
+ * Radar's per-view titles can do the same.
99
+ */
100
+ manageDocumentTitle?: boolean;
101
+ /**
102
+ * Trailing string appended after the per-view label (only when
103
+ * `manageDocumentTitle` is on). It's the *full* suffix including any
104
+ * separator, so a host can rebrand (`' — My Cloud'`) or drop it (`''`).
105
+ * Defaults to `' · Radar'`.
106
+ */
107
+ documentTitleSuffix?: string;
108
+ /**
109
+ * Injects a resource-level "Diagnose" action (e.g. a "Diagnose with AI"
110
+ * button) into every resource detail action bar's right-aligned universal
111
+ * actions. The host returns the node to render given the resource context.
112
+ * Standalone Radar omits this and renders no Diagnose button — OSS stays
113
+ * agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
114
+ */
115
+ renderDiagnoseAction?: RenderDiagnoseAction;
116
+ /**
117
+ * Replaces the first-run consent card's trust copy. REQUIRED of any host whose
118
+ * backend runs the agent somewhere other than the user's own machine — the
119
+ * default copy states the agent runs locally, under the user's own model
120
+ * account, with transcripts kept on their disk, and none of that is true of a
121
+ * hosted runner. Radar keeps the card's chrome and the Approve/Cancel flow; a
122
+ * host only supplies the claims. See ./context/DiagnoseCustomization.
123
+ */
124
+ diagnoseConsent?: DiagnoseConsentCopy;
125
+ /**
126
+ * Initial route for `router: 'memory'` (ignored for 'browser'). Lets a host
127
+ * deep-link a specific view (e.g. '/topology') without owning the URL bar —
128
+ * used with `navSlots.chrome: 'none'` to render a single per-cluster view
129
+ * chromeless under the host's own chrome (Radar Hub's per-cluster destinations).
130
+ */
131
+ initialPath?: string;
132
+ /**
133
+ * Reports cluster-data warmup after the main connection is usable. Embedders
134
+ * with their own chrome (Radar Hub) can render this in their topbar while
135
+ * Radar runs with `navSlots.chrome: 'none'`.
136
+ */
137
+ onClusterLoadStateChange?: (state: ClusterLoadState) => void;
138
+ /**
139
+ * Selects the store backing the event timeline. Omit for the local event
140
+ * store the Radar binary keeps (default, standalone behavior). Set
141
+ * `{ mode: 'retained' }` when embedding behind a proxy that serves a
142
+ * longer-horizon history at `{apiBase}/timeline/events` +
143
+ * `{apiBase}/timeline/overview`; `maxRangeDays` caps how far back the
144
+ * 'all' range reaches. Generic extension point — the backend that answers
145
+ * the retained endpoints is the host's concern.
146
+ *
147
+ * Changing `mode` between renders remounts the timeline view (the local and
148
+ * retained sources expose different `useEvents` hooks; remounting avoids a
149
+ * React hook-order violation). Set it once at mount when possible.
150
+ */
151
+ timelineSource?: TimelineSourceConfig;
73
152
  }
74
153
 
75
154
  // Default QueryClient with the same shape Radar's standalone binary uses.
@@ -90,13 +169,18 @@ function makeDefaultQueryClient(): QueryClient {
90
169
  },
91
170
  onSuccess: (_data, _variables, _context, mutation) => {
92
171
  const message = mutation.options.meta?.successMessage;
93
- if (message) showApiSuccess(message, mutation.options.meta?.successDetail);
172
+ if (message)
173
+ showApiSuccess(message, mutation.options.meta?.successDetail);
94
174
  },
95
175
  }),
96
176
  queryCache: new QueryCache({
97
177
  onError: (error, query) => {
98
178
  if (query.state.data !== undefined) {
99
- console.warn('[Background sync failed]', query.queryKey, (error as Error).message);
179
+ console.warn(
180
+ "[Background sync failed]",
181
+ query.queryKey,
182
+ (error as Error).message,
183
+ );
100
184
  }
101
185
  },
102
186
  }),
@@ -106,9 +190,16 @@ function makeDefaultQueryClient(): QueryClient {
106
190
  export function RadarApp({
107
191
  apiBase,
108
192
  basename,
109
- router = 'browser',
193
+ router = "browser",
110
194
  queryClient,
111
195
  navSlots,
196
+ manageDocumentTitle = false,
197
+ documentTitleSuffix,
198
+ renderDiagnoseAction,
199
+ diagnoseConsent,
200
+ initialPath,
201
+ onClusterLoadStateChange,
202
+ timelineSource,
112
203
  }: RadarAppProps): React.ReactElement {
113
204
  // Apply runtime config during render so module-level singletons are set
114
205
  // before children construct URLs. getApiBase() / getAuthHeaders() /
@@ -121,25 +212,47 @@ export function RadarApp({
121
212
 
122
213
  // Memo so we don't recreate the QueryClient on every render when the
123
214
  // consumer didn't pass one.
124
- const client = React.useMemo(() => queryClient ?? makeDefaultQueryClient(), [queryClient]);
215
+ const client = React.useMemo(
216
+ () => queryClient ?? makeDefaultQueryClient(),
217
+ [queryClient],
218
+ );
125
219
 
126
220
  const inner = (
127
221
  <ThemeProvider>
128
222
  <QueryClientProvider client={client}>
129
223
  <ToastProvider>
130
224
  <NavCustomizationProvider value={navSlots}>
131
- <App />
225
+ <FilterLocationBridge>
226
+ <TimelineSourceProvider config={timelineSource}>
227
+ <DiagnoseCustomizationProvider
228
+ value={renderDiagnoseAction ?? defaultDiagnoseAction}
229
+ consentCopy={diagnoseConsent}
230
+ >
231
+ <DiagnoseProvider>
232
+ <App
233
+ manageDocumentTitle={manageDocumentTitle}
234
+ documentTitleSuffix={documentTitleSuffix}
235
+ onClusterLoadStateChange={onClusterLoadStateChange}
236
+ />
237
+ </DiagnoseProvider>
238
+ </DiagnoseCustomizationProvider>
239
+ </TimelineSourceProvider>
240
+ </FilterLocationBridge>
132
241
  </NavCustomizationProvider>
133
242
  </ToastProvider>
134
243
  </QueryClientProvider>
135
244
  </ThemeProvider>
136
245
  );
137
246
 
138
- if (router === 'memory') {
139
- return <MemoryRouter initialEntries={['/']}>{inner}</MemoryRouter>;
247
+ if (router === "memory") {
248
+ return (
249
+ <MemoryRouter initialEntries={[initialPath || "/"]}>{inner}</MemoryRouter>
250
+ );
140
251
  }
141
252
 
142
- return <BrowserRouter basename={basename || undefined}>{inner}</BrowserRouter>;
253
+ return (
254
+ <BrowserRouter basename={basename || undefined}>{inner}</BrowserRouter>
255
+ );
143
256
  }
144
257
 
145
258
  export default RadarApp;
@@ -3,7 +3,7 @@ import type { APIResource } from '../types'
3
3
  import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
4
4
 
5
5
  // Re-export pure functions from package
6
- export { categorizeResources, CORE_RESOURCES, formatGroupName, shortenGroupName, getKindLabel, getKindPlural } from '@skyhook-io/k8s-ui'
6
+ export { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute, formatGroupName, shortenGroupName, getKindLabel, getKindPlural } from '@skyhook-io/k8s-ui'
7
7
  export type { ResourceCategory } from '@skyhook-io/k8s-ui'
8
8
 
9
9
  async function fetchJSON<T>(path: string): Promise<T> {
@@ -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
+ })