@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.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- 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/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- 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
|
|
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
|
-
|
|
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
|
|
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?:
|
|
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)
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
<
|
|
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 ===
|
|
139
|
-
return
|
|
247
|
+
if (router === "memory") {
|
|
248
|
+
return (
|
|
249
|
+
<MemoryRouter initialEntries={[initialPath || "/"]}>{inner}</MemoryRouter>
|
|
250
|
+
);
|
|
140
251
|
}
|
|
141
252
|
|
|
142
|
-
return
|
|
253
|
+
return (
|
|
254
|
+
<BrowserRouter basename={basename || undefined}>{inner}</BrowserRouter>
|
|
255
|
+
);
|
|
143
256
|
}
|
|
144
257
|
|
|
145
258
|
export default RadarApp;
|
package/src/api/apiResources.ts
CHANGED
|
@@ -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
|
+
})
|