@skyhook-io/radar-app 1.16.3 → 1.17.0
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/package.json +2 -2
- package/src/App.tsx +14 -7
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +115 -8
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGProtection.tsx +2 -2
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +3 -3
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +3 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +7 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +135 -290
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"yaml": "^2.9.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
45
|
+
"@skyhook-io/k8s-ui": ">=1.17.0",
|
|
46
46
|
"@tanstack/react-query": ">=5",
|
|
47
47
|
"@xyflow/react": ">=12.0.0",
|
|
48
48
|
"clsx": ">=2",
|
package/src/App.tsx
CHANGED
|
@@ -60,7 +60,7 @@ import { UsageDataPrompt } from './components/usage-data/UsageDataPrompt'
|
|
|
60
60
|
import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
61
61
|
import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
62
62
|
import { useEventSource } from './hooks/useEventSource'
|
|
63
|
-
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
|
|
63
|
+
import { debugNamespaceLog, refetchOnResourceEvents, resourceEventKey, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
|
|
64
64
|
import { buildAuditSeverityMap } from './utils/auditBadges'
|
|
65
65
|
import { isInNamespaceScope, scopeNodesToNamespaces } from './utils/topology-namespace'
|
|
66
66
|
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode, stripBasename } from './api/config'
|
|
@@ -1069,12 +1069,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1069
1069
|
// update that arrived *after* the fast structural flush already ran.
|
|
1070
1070
|
const fastInvalidationRef = useRef<{
|
|
1071
1071
|
changedKinds: Set<string> // every changed kind (any op) → detail drawer
|
|
1072
|
+
changedObjects: Set<string> // resourceEventKey of every event → detail views that got a 404
|
|
1072
1073
|
structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
|
|
1073
1074
|
environmentNamespaces: Set<string>
|
|
1074
1075
|
environmentPods: Map<string, Set<string>>
|
|
1075
1076
|
secretsChanged: boolean
|
|
1076
1077
|
timer: number | null
|
|
1077
|
-
}>({ changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null })
|
|
1078
|
+
}>({ changedKinds: new Set(), changedObjects: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null })
|
|
1078
1079
|
const cnpgInvalidationPendingRef = useRef(false)
|
|
1079
1080
|
const slowInvalidationRef = useRef<{
|
|
1080
1081
|
updatedKinds: Set<string> // update-only churn → throttled list + dashboard
|
|
@@ -1111,6 +1112,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1111
1112
|
|
|
1112
1113
|
const fast = fastInvalidationRef.current
|
|
1113
1114
|
fast.changedKinds.add(kind)
|
|
1115
|
+
fast.changedObjects.add(resourceEventKey(kind, event.namespace ?? '', event.name))
|
|
1114
1116
|
if (structural) fast.structuralKinds.add(kind)
|
|
1115
1117
|
if (kind === 'secrets') fast.secretsChanged = true
|
|
1116
1118
|
if ((kind === 'configmaps' || kind === 'secrets') && event.namespace) fast.environmentNamespaces.add(event.namespace)
|
|
@@ -1128,7 +1130,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1128
1130
|
fast.timer = window.setTimeout(() => {
|
|
1129
1131
|
const f = fastInvalidationRef.current
|
|
1130
1132
|
for (const k of f.changedKinds) {
|
|
1131
|
-
|
|
1133
|
+
// Open detail drawers stay live; a deleted one is left alone unless
|
|
1134
|
+
// an event names it.
|
|
1135
|
+
queryClient.invalidateQueries({
|
|
1136
|
+
queryKey: ['resource', k],
|
|
1137
|
+
predicate: (q) => refetchOnResourceEvents(q, k, f.changedObjects),
|
|
1138
|
+
})
|
|
1132
1139
|
}
|
|
1133
1140
|
for (const k of f.structuralKinds) {
|
|
1134
1141
|
queryClient.invalidateQueries({ queryKey: ['resources', k] }) // list membership changed
|
|
@@ -1156,7 +1163,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1156
1163
|
cnpgInvalidationPendingRef.current = false
|
|
1157
1164
|
queryClient.invalidateQueries({ queryKey: ['cnpg', 'workspace'] })
|
|
1158
1165
|
}
|
|
1159
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1166
|
+
fastInvalidationRef.current = { changedKinds: new Set(), changedObjects: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1160
1167
|
}, 3000)
|
|
1161
1168
|
}
|
|
1162
1169
|
|
|
@@ -1185,7 +1192,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1185
1192
|
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
1186
1193
|
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
1187
1194
|
if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
|
|
1188
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1195
|
+
fastInvalidationRef.current = { changedKinds: new Set(), changedObjects: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1189
1196
|
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1190
1197
|
timelineInvalidationRef.current = { timer: null }
|
|
1191
1198
|
}, [])
|
|
@@ -1208,7 +1215,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1208
1215
|
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
1209
1216
|
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
1210
1217
|
if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
|
|
1211
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1218
|
+
fastInvalidationRef.current = { changedKinds: new Set(), changedObjects: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1212
1219
|
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1213
1220
|
timelineInvalidationRef.current = { timer: null }
|
|
1214
1221
|
|
|
@@ -2313,7 +2320,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2313
2320
|
|
|
2314
2321
|
{/* Traffic view */}
|
|
2315
2322
|
{!viewsSyncGated && mainView === 'traffic' && (
|
|
2316
|
-
<TrafficView namespaces={namespaces} />
|
|
2323
|
+
<TrafficView namespaces={namespaces} onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }} />
|
|
2317
2324
|
)}
|
|
2318
2325
|
|
|
2319
2326
|
{/* Cost detail view */}
|
package/src/RadarApp.tsx
CHANGED
|
@@ -46,6 +46,7 @@ import type {
|
|
|
46
46
|
import type { ClusterLoadState } from "./types/clusterLoadState";
|
|
47
47
|
import { TimelineSourceProvider } from "./context/TimelineSource";
|
|
48
48
|
import type { TimelineSourceConfig } from "./api/timelineSource";
|
|
49
|
+
import { TimelineRetentionNoteProvider } from "./context/TimelineCustomization";
|
|
49
50
|
import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
|
|
50
51
|
import type {
|
|
51
52
|
RenderDiagnoseAction,
|
|
@@ -180,6 +181,12 @@ export interface RadarAppProps {
|
|
|
180
181
|
* React hook-order violation). Set it once at mount when possible.
|
|
181
182
|
*/
|
|
182
183
|
timelineSource?: TimelineSourceConfig;
|
|
184
|
+
/**
|
|
185
|
+
* A note shown above the timeline and the Applications history, saying how
|
|
186
|
+
* far back history goes. Omitted → no note. Set it only when the timeline server
|
|
187
|
+
* actually limits history, or the note misstates what is shown.
|
|
188
|
+
*/
|
|
189
|
+
timelineRetentionNote?: React.ReactNode;
|
|
183
190
|
}
|
|
184
191
|
|
|
185
192
|
// Default QueryClient with the same shape Radar's standalone binary uses.
|
|
@@ -236,6 +243,7 @@ export function RadarApp({
|
|
|
236
243
|
onClusterLoadStateChange,
|
|
237
244
|
onInvestigationFocus,
|
|
238
245
|
timelineSource,
|
|
246
|
+
timelineRetentionNote,
|
|
239
247
|
}: RadarAppProps): React.ReactElement {
|
|
240
248
|
// Apply runtime config during render so module-level singletons are set
|
|
241
249
|
// before children construct URLs. getApiBase() / getAuthHeaders() /
|
|
@@ -266,24 +274,26 @@ export function RadarApp({
|
|
|
266
274
|
<RadarUpgradeHostProvider value={upgradeHost}>
|
|
267
275
|
<FilterLocationBridge>
|
|
268
276
|
<TimelineSourceProvider config={timelineSource}>
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<DiagnoseProvider
|
|
276
|
-
browserURLState={router !== "memory"}
|
|
277
|
-
forceRouterURLState={router === "memory"}
|
|
278
|
-
onFocusedRun={onInvestigationFocus}
|
|
277
|
+
<TimelineRetentionNoteProvider value={timelineRetentionNote}>
|
|
278
|
+
<DiagnoseCustomizationProvider
|
|
279
|
+
value={renderDiagnoseAction ?? defaultDiagnoseAction}
|
|
280
|
+
consentCopy={diagnoseConsent}
|
|
281
|
+
renderRunActions={renderInvestigationRunActions}
|
|
282
|
+
renderRefusalAction={renderInvestigationRefusalAction}
|
|
279
283
|
>
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
<DiagnoseProvider
|
|
285
|
+
browserURLState={router !== "memory"}
|
|
286
|
+
forceRouterURLState={router === "memory"}
|
|
287
|
+
onFocusedRun={onInvestigationFocus}
|
|
288
|
+
>
|
|
289
|
+
<App
|
|
290
|
+
manageDocumentTitle={manageDocumentTitle}
|
|
291
|
+
documentTitleSuffix={documentTitleSuffix}
|
|
292
|
+
onClusterLoadStateChange={onClusterLoadStateChange}
|
|
293
|
+
/>
|
|
294
|
+
</DiagnoseProvider>
|
|
295
|
+
</DiagnoseCustomizationProvider>
|
|
296
|
+
</TimelineRetentionNoteProvider>
|
|
287
297
|
</TimelineSourceProvider>
|
|
288
298
|
</FilterLocationBridge>
|
|
289
299
|
</RadarUpgradeHostProvider>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ApiError, goneRecheckInterval, refetchOnResourceEvents, resourceEventKey } from "./client";
|
|
3
|
+
|
|
4
|
+
const notFound = new ApiError('pods "web-1" not found', 404);
|
|
5
|
+
const detail = (namespace: string, name: string, error: unknown) => ({
|
|
6
|
+
queryKey: ["resource", "pods", namespace, name, undefined],
|
|
7
|
+
state: { error },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("refetchOnResourceEvents", () => {
|
|
11
|
+
it("refetches a live detail view on any change to its kind", () => {
|
|
12
|
+
expect(refetchOnResourceEvents(detail("shop", "web-1", null), "pods", new Set())).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("refetches a detail view that failed for a reason other than 404", () => {
|
|
16
|
+
const failed = detail("shop", "web-1", new ApiError("unavailable", 503));
|
|
17
|
+
expect(refetchOnResourceEvents(failed, "pods", new Set())).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("leaves a deleted object alone when other objects of its kind change", () => {
|
|
21
|
+
// The production shape: pods churning next to an open view of a deleted
|
|
22
|
+
// pod refetched it, 404 every time, for as long as the view stayed open.
|
|
23
|
+
const named = new Set([resourceEventKey("pods", "shop", "web-2")]);
|
|
24
|
+
expect(refetchOnResourceEvents(detail("shop", "web-1", notFound), "pods", named)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("refetches a deleted object as soon as an event names it", () => {
|
|
28
|
+
// A StatefulSet recreates its pod under the same name.
|
|
29
|
+
const named = new Set([resourceEventKey("pods", "shop", "web-1")]);
|
|
30
|
+
expect(refetchOnResourceEvents(detail("shop", "web-1", notFound), "pods", named)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("matches a cluster-scoped object, whose namespace is empty", () => {
|
|
34
|
+
const query = { queryKey: ["resource", "nodes", "", "node-a", undefined], state: { error: notFound } };
|
|
35
|
+
expect(refetchOnResourceEvents(query, "nodes", new Set([resourceEventKey("nodes", "", "node-a")]))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("goneRecheckInterval", () => {
|
|
40
|
+
it("re-checks a deleted object once a minute", () => {
|
|
41
|
+
expect(goneRecheckInterval({ state: { error: notFound } }, undefined)).toBe(60_000);
|
|
42
|
+
expect(goneRecheckInterval({ state: { error: notFound } }, 5_000)).toBe(60_000);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps the caller's interval otherwise", () => {
|
|
46
|
+
expect(goneRecheckInterval({ state: { error: null } }, 5_000)).toBe(5_000);
|
|
47
|
+
expect(goneRecheckInterval({ state: { error: new ApiError("unavailable", 503) } }, undefined)).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/api/client.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { canonicalResourceGroup } from '@skyhook-io/k8s-ui/utils/api-resources'
|
|
|
2
2
|
import { knownKindForPluralWithGroup, pluralToKind } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
3
3
|
import { useEffect, useRef } from 'react'
|
|
4
4
|
import type { KueueAdmissionResponse } from '@skyhook-io/k8s-ui/types/scheduling'
|
|
5
|
+
import type { GitOpsWriteEvidence } from '@skyhook-io/k8s-ui/utils/gitops-write-guard'
|
|
5
6
|
import type {
|
|
6
7
|
AppHistory,
|
|
7
8
|
AppRow,
|
|
@@ -439,7 +440,7 @@ export interface DashboardResourceCounts {
|
|
|
439
440
|
ingresses: number;
|
|
440
441
|
gateways?: number;
|
|
441
442
|
routes?: number;
|
|
442
|
-
nodes: { total: number; ready: number; notReady: number; cordoned: number };
|
|
443
|
+
nodes: { total: number; ready: number; notReady: number; cordoned: number; removing?: number; removingUnhealthy?: number };
|
|
443
444
|
namespaces: number;
|
|
444
445
|
jobs: { total: number; active: number; succeeded: number; failed: number };
|
|
445
446
|
cronJobs: { total: number; active: number; suspended: number };
|
|
@@ -2508,6 +2509,24 @@ export function useGitOpsInsights(
|
|
|
2508
2509
|
});
|
|
2509
2510
|
}
|
|
2510
2511
|
|
|
2512
|
+
// Field-level evidence behind the GitOps write guard: whether each path is in
|
|
2513
|
+
// the object's last client-side apply or owned by its GitOps controller, plus
|
|
2514
|
+
// the owner's sync policy. Read server-side because Radar's caches strip both.
|
|
2515
|
+
export function fetchGitOpsWriteEvidence(body: {
|
|
2516
|
+
kind: string;
|
|
2517
|
+
group: string;
|
|
2518
|
+
namespace: string;
|
|
2519
|
+
name: string;
|
|
2520
|
+
paths: string[];
|
|
2521
|
+
owner?: { kind: string; group: string; namespace: string; name: string };
|
|
2522
|
+
}): Promise<GitOpsWriteEvidence> {
|
|
2523
|
+
return fetchJSON<GitOpsWriteEvidence>("/gitops/write-evidence", {
|
|
2524
|
+
method: "POST",
|
|
2525
|
+
headers: { "Content-Type": "application/json" },
|
|
2526
|
+
body: JSON.stringify(body),
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2511
2530
|
// Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
|
|
2512
2531
|
// ns/name identify the Application; the ref identifies the managed resource.
|
|
2513
2532
|
// Fetched on demand — the caller mounts this only when the user opens "Full
|
|
@@ -2597,18 +2616,55 @@ export function fetchResourceWithRelationships<T>(
|
|
|
2597
2616
|
);
|
|
2598
2617
|
}
|
|
2599
2618
|
|
|
2619
|
+
/**
|
|
2620
|
+
* How often an open detail view re-checks a resource the server said is gone.
|
|
2621
|
+
* Watch events stop refetching it (see refetchOnResourceEvents), so this is
|
|
2622
|
+
* what finds one recreated under the same name when no event names it, such
|
|
2623
|
+
* as a ConfigMap: the server streams adds only for pods.
|
|
2624
|
+
*/
|
|
2625
|
+
const GONE_RECHECK_MS = 60_000;
|
|
2626
|
+
|
|
2627
|
+
export function goneRecheckInterval(
|
|
2628
|
+
query: { state: { error: unknown } },
|
|
2629
|
+
otherwise: number | false | undefined,
|
|
2630
|
+
): number | false | undefined {
|
|
2631
|
+
return isNotFoundError(query.state.error) ? GONE_RECHECK_MS : otherwise;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
export function resourceEventKey(kind: string, namespace: string, name: string): string {
|
|
2635
|
+
return `${kind}/${namespace}/${name}`;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
/**
|
|
2639
|
+
* Whether a batch of watch events for `kind` refetches this ['resource', kind,
|
|
2640
|
+
* namespace, name, group] query. One the server answered 404 for is refetched
|
|
2641
|
+
* only when an event in the batch names its object; otherwise every change to
|
|
2642
|
+
* the kind would ask again for something that is gone.
|
|
2643
|
+
*/
|
|
2644
|
+
export function refetchOnResourceEvents(
|
|
2645
|
+
query: { queryKey: readonly unknown[]; state: { error: unknown } },
|
|
2646
|
+
kind: string,
|
|
2647
|
+
namedObjects: ReadonlySet<string>,
|
|
2648
|
+
): boolean {
|
|
2649
|
+
if (!isNotFoundError(query.state.error)) return true;
|
|
2650
|
+
const [, , namespace, name] = query.queryKey;
|
|
2651
|
+
return namedObjects.has(resourceEventKey(kind, String(namespace ?? ""), String(name ?? "")));
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// `cacheScope` keeps a caller's copy apart from the shared one, for hosts
|
|
2655
|
+
// that point one QueryClient at several clusters (see useGitOpsWriteGuard).
|
|
2600
2656
|
export function useResource<T>(
|
|
2601
2657
|
kind: string,
|
|
2602
2658
|
namespace: string,
|
|
2603
2659
|
name: string,
|
|
2604
2660
|
group?: string,
|
|
2605
|
-
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
2661
|
+
options?: { enabled?: boolean; refetchInterval?: number | false; cacheScope?: string },
|
|
2606
2662
|
) {
|
|
2607
2663
|
const query = useQuery<ResourceWithRelationships<T>>({
|
|
2608
|
-
queryKey: ["resource", kind, namespace, name, group],
|
|
2664
|
+
queryKey: ["resource", kind, namespace, name, group, ...(options?.cacheScope ? [options.cacheScope] : [])],
|
|
2609
2665
|
queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
|
|
2610
2666
|
enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
|
|
2611
|
-
refetchInterval: options?.refetchInterval,
|
|
2667
|
+
refetchInterval: (query) => goneRecheckInterval(query, options?.refetchInterval),
|
|
2612
2668
|
// Kind still completing its initial sync: stay in loading and poll until
|
|
2613
2669
|
// it becomes readable instead of erroring out (deep links during startup).
|
|
2614
2670
|
retry: (failureCount, error) => {
|
|
@@ -2636,11 +2692,13 @@ export function useResourceWithRelationships<T>(
|
|
|
2636
2692
|
namespace: string,
|
|
2637
2693
|
name: string,
|
|
2638
2694
|
group?: string,
|
|
2695
|
+
options?: { cacheScope?: string },
|
|
2639
2696
|
) {
|
|
2640
2697
|
return useQuery<ResourceWithRelationships<T>>({
|
|
2641
|
-
queryKey: ["resource", kind, namespace, name, group],
|
|
2698
|
+
queryKey: ["resource", kind, namespace, name, group, ...(options?.cacheScope ? [options.cacheScope] : [])],
|
|
2642
2699
|
queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
|
|
2643
2700
|
enabled: Boolean(kind && name),
|
|
2701
|
+
refetchInterval: (query) => goneRecheckInterval(query, undefined),
|
|
2644
2702
|
// Deep-linked detail views can mount while the kind's informer is still
|
|
2645
2703
|
// completing its initial sync: keep polling instead of erroring out.
|
|
2646
2704
|
retry: (failureCount, error) => {
|
|
@@ -2658,7 +2716,7 @@ export function useResources<T>(
|
|
|
2658
2716
|
kind: string,
|
|
2659
2717
|
namespace?: string,
|
|
2660
2718
|
group?: string,
|
|
2661
|
-
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
2719
|
+
options?: { enabled?: boolean; refetchInterval?: number | false; cacheScope?: string },
|
|
2662
2720
|
) {
|
|
2663
2721
|
const params = new URLSearchParams();
|
|
2664
2722
|
if (namespace) params.set("namespace", namespace);
|
|
@@ -2666,7 +2724,7 @@ export function useResources<T>(
|
|
|
2666
2724
|
const queryString = params.toString();
|
|
2667
2725
|
|
|
2668
2726
|
return useQuery<T[]>({
|
|
2669
|
-
queryKey: ["resources", kind, group, namespace],
|
|
2727
|
+
queryKey: ["resources", kind, group, namespace, ...(options?.cacheScope ? [options.cacheScope] : [])],
|
|
2670
2728
|
queryFn: () =>
|
|
2671
2729
|
fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ""}`),
|
|
2672
2730
|
enabled: (options?.enabled ?? true) && Boolean(kind),
|
|
@@ -2981,6 +3039,38 @@ export function useWorkloadHistory(kind: string, namespace: string, name: string
|
|
|
2981
3039
|
return query;
|
|
2982
3040
|
}
|
|
2983
3041
|
|
|
3042
|
+
export interface ServiceEndpointSlicesResult {
|
|
3043
|
+
items: any[];
|
|
3044
|
+
/** More slices are labeled for the Service than the server returns in one page. */
|
|
3045
|
+
truncated: boolean;
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// The EndpointSlices published for one Service, selected server-side by the
|
|
3049
|
+
// kubernetes.io/service-name label. A Radar that predates the endpoint settles
|
|
3050
|
+
// as unsupported; ServiceRenderer then falls back to the namespace list.
|
|
3051
|
+
export function useServiceEndpointSlices(namespace: string, name: string, enabled = true) {
|
|
3052
|
+
const { guard, gatedKey, support } = useRadarFeature("serviceEndpointSlices");
|
|
3053
|
+
const query = useQuery<ServiceEndpointSlicesResult>({
|
|
3054
|
+
queryKey: ["service-endpointslices", namespace, name, ...gatedKey],
|
|
3055
|
+
queryFn: ({ signal }) =>
|
|
3056
|
+
guard(() =>
|
|
3057
|
+
fetchJSON<ServiceEndpointSlicesResult>(
|
|
3058
|
+
`/services/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/endpointslices`,
|
|
3059
|
+
signal,
|
|
3060
|
+
),
|
|
3061
|
+
),
|
|
3062
|
+
retry: shouldRetryRadarQuery,
|
|
3063
|
+
refetchInterval: (query) =>
|
|
3064
|
+
query.state.error instanceof RadarFeatureUnsupportedError ? false : 30000,
|
|
3065
|
+
enabled,
|
|
3066
|
+
});
|
|
3067
|
+
const { error, refetch } = query;
|
|
3068
|
+
useEffect(() => {
|
|
3069
|
+
if (support === "supported" && error instanceof RadarFeatureUnsupportedError) void refetch();
|
|
3070
|
+
}, [support, error, refetch]);
|
|
3071
|
+
return query;
|
|
3072
|
+
}
|
|
3073
|
+
|
|
2984
3074
|
export function fetchWorkloadHistoryPage(kind: string, namespace: string, name: string, group: string | undefined, beforeSeq: number): Promise<WorkloadHistoryPage> {
|
|
2985
3075
|
return fetchJSON(workloadHistoryPath(kind, namespace, name, group, beforeSeq));
|
|
2986
3076
|
}
|
|
@@ -4102,7 +4192,7 @@ export function useUpdateResource() {
|
|
|
4102
4192
|
});
|
|
4103
4193
|
if (!response.ok) {
|
|
4104
4194
|
const error = await readErrorBody(response);
|
|
4105
|
-
const failure = new
|
|
4195
|
+
const failure = new ApiError(error.error || `HTTP ${response.status}`, response.status, error);
|
|
4106
4196
|
// A reviewed apply's failure is shown in the review itself (the
|
|
4107
4197
|
// changed-after-review notice, or the error), so it gets no toast.
|
|
4108
4198
|
if (reviewedResourceVersion !== undefined || reviewedContext !== undefined) {
|
|
@@ -5332,6 +5422,23 @@ export function useDrainPlan() {
|
|
|
5332
5422
|
});
|
|
5333
5423
|
}
|
|
5334
5424
|
|
|
5425
|
+
export function useNodeRemovalPlan(name: string | undefined, enabled: boolean) {
|
|
5426
|
+
const { guard, gatedKey } = useRadarFeature('drainPlan')
|
|
5427
|
+
return useQuery<DrainPlan>({
|
|
5428
|
+
queryKey: ['resources', 'node-removal-plan', name, ...gatedKey],
|
|
5429
|
+
queryFn: ({ signal }) => guard(() => fetchJSON<DrainPlan>(drainPlanPath(name!), {
|
|
5430
|
+
method: 'POST',
|
|
5431
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5432
|
+
body: drainPlanBody({ force: true, deleteEmptyDirData: true }),
|
|
5433
|
+
signal,
|
|
5434
|
+
})),
|
|
5435
|
+
enabled: enabled && Boolean(name),
|
|
5436
|
+
staleTime: ISSUES_REFRESH_INTERVAL_MS,
|
|
5437
|
+
refetchInterval: (query) => query.state.error instanceof RadarFeatureUnsupportedError ? false : ISSUES_REFRESH_INTERVAL_MS,
|
|
5438
|
+
retry: shouldRetryRadarQuery,
|
|
5439
|
+
})
|
|
5440
|
+
}
|
|
5441
|
+
|
|
5335
5442
|
export function useDrainNode() {
|
|
5336
5443
|
const queryClient = useQueryClient();
|
|
5337
5444
|
|
package/src/api/diagnose.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
addTurn,
|
|
4
4
|
createRun,
|
|
5
5
|
DiagnoseError,
|
|
6
|
+
fetchAgents,
|
|
6
7
|
subscribeRun,
|
|
7
8
|
type DiagnoseStreamEvent,
|
|
8
9
|
} from "./diagnose";
|
|
@@ -367,3 +368,42 @@ describe("refused requests", () => {
|
|
|
367
368
|
expect((await startError()).message).toBe(message);
|
|
368
369
|
});
|
|
369
370
|
});
|
|
371
|
+
|
|
372
|
+
describe("fetchAgents agents normalisation", () => {
|
|
373
|
+
const withFetch = async (body: unknown) => {
|
|
374
|
+
const original = globalThis.fetch;
|
|
375
|
+
globalThis.fetch = (async () =>
|
|
376
|
+
new Response(JSON.stringify(body), {
|
|
377
|
+
status: 200,
|
|
378
|
+
headers: { "content-type": "application/json" },
|
|
379
|
+
})) as typeof fetch;
|
|
380
|
+
try {
|
|
381
|
+
return await fetchAgents();
|
|
382
|
+
} finally {
|
|
383
|
+
globalThis.fetch = original;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
it("turns the server's null agent list into an empty array", async () => {
|
|
388
|
+
// Older servers send null for a machine with no CLI installed, the case
|
|
389
|
+
// callers most need to survive.
|
|
390
|
+
const r = await withFetch({
|
|
391
|
+
agents: null,
|
|
392
|
+
enabled: false,
|
|
393
|
+
eligible: true,
|
|
394
|
+
consented: {},
|
|
395
|
+
});
|
|
396
|
+
expect(r.agents).toEqual([]);
|
|
397
|
+
expect(r.eligible).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("leaves a populated list alone", async () => {
|
|
401
|
+
const r = await withFetch({
|
|
402
|
+
agents: [{ name: "claude", label: "Claude Code", supported: true }],
|
|
403
|
+
enabled: true,
|
|
404
|
+
eligible: true,
|
|
405
|
+
consented: {},
|
|
406
|
+
});
|
|
407
|
+
expect(r.agents.map((a) => a.name)).toEqual(["claude"]);
|
|
408
|
+
});
|
|
409
|
+
});
|
package/src/api/diagnose.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface AgentsResponse {
|
|
|
29
29
|
// "install an agent to enable this" (eligible && !enabled) apart from "not
|
|
30
30
|
// available here" (auth/cloud/--no-mcp). Absent on older servers / embed hosts.
|
|
31
31
|
eligible?: boolean;
|
|
32
|
+
// cliOverride: RADAR_AI_CLI_BIN is set, pinning the engine to one CLI. With
|
|
33
|
+
// CLIs detected and investigations still off, that variable is what's broken.
|
|
34
|
+
// Absent on older servers and embed hosts.
|
|
35
|
+
cliOverride?: boolean;
|
|
32
36
|
// Machine-scoped consent per disclosure surface, recorded server-side
|
|
33
37
|
// (~/.radar) — one acknowledgment covers the web panel and the CLI.
|
|
34
38
|
consented?: Record<string, boolean>;
|
|
@@ -257,7 +261,14 @@ export async function fetchAgents(
|
|
|
257
261
|
signal,
|
|
258
262
|
});
|
|
259
263
|
if (!res.ok) throw new Error(`agents: ${res.status}`);
|
|
260
|
-
|
|
264
|
+
const body = (await res.json()) as AgentsResponse & {
|
|
265
|
+
agents: AgentInfo[] | null;
|
|
266
|
+
};
|
|
267
|
+
// Older Radar servers send "no agent found" as JSON null, not []. This client
|
|
268
|
+
// also runs against servers of other versions, and that is the single most
|
|
269
|
+
// common response on a machine with no CLI, so normalise here rather than in
|
|
270
|
+
// every caller that reaches for .filter.
|
|
271
|
+
return { ...body, agents: body.agents ?? [] };
|
|
261
272
|
}
|
|
262
273
|
|
|
263
274
|
/**
|
|
@@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client'
|
|
|
4
4
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
5
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
6
6
|
import { getRadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
|
|
7
|
-
import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, usePodEnvironment, useWorkloadHistory } from './client'
|
|
7
|
+
import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, useNodeRemovalPlan, usePodEnvironment, useWorkloadHistory } from './client'
|
|
8
8
|
import { getApiBase } from './config'
|
|
9
9
|
import { usePolicyResource } from './policy'
|
|
10
10
|
import { isRadarFeatureUnsupported } from './radarFeatures'
|
|
@@ -153,6 +153,24 @@ function DrainPlanProbe() {
|
|
|
153
153
|
return null
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
function RemovalPlanProbe() {
|
|
157
|
+
const { error } = useNodeRemovalPlan('worker-1', true)
|
|
158
|
+
return <span>{getRadarUpgradeRequirement(error) ? 'upgrade' : 'pending'}</span>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it('does not poll an old Radar for removal details', async () => {
|
|
162
|
+
host.radarVersion = 'v1.13.1'
|
|
163
|
+
await render(<RemovalPlanProbe />)
|
|
164
|
+
expect(element.textContent).toBe('upgrade')
|
|
165
|
+
expect(asked('/drain-plan')).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('requests removal details once the drain-plan feature is supported', async () => {
|
|
169
|
+
client.setQueryData(['capabilities'], { features: { resourceIssues: true } })
|
|
170
|
+
await render(<RemovalPlanProbe />)
|
|
171
|
+
expect(asked('/drain-plan')).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
156
174
|
it('never asks a Radar older than v1.14 for a drain plan', async () => {
|
|
157
175
|
host.radarVersion = 'v1.13.1'
|
|
158
176
|
await render(<DrainPlanProbe />)
|
package/src/api/radarFeatures.ts
CHANGED
|
@@ -35,6 +35,9 @@ export const RADAR_FEATURES = {
|
|
|
35
35
|
drainPlan: { label: 'Drain plans', minimumVersion: 'v1.14.0' },
|
|
36
36
|
applications: { label: 'Applications', minimumVersion: 'v1.7.7' },
|
|
37
37
|
workloadHistory: { label: 'Workload history', flag: 'workloadHistory', flagShippedWithEndpoint: true },
|
|
38
|
+
serviceEndpointSlices: { label: 'Service EndpointSlice inventories', flag: 'serviceEndpointSlices', flagShippedWithEndpoint: true },
|
|
39
|
+
trafficRecords: { label: 'Flow records for a graph selection', flag: 'trafficRecords', flagShippedWithEndpoint: true },
|
|
40
|
+
gitopsWriteEvidence: { label: 'GitOps revert warnings', flag: 'gitopsWriteEvidence', flagShippedWithEndpoint: true },
|
|
38
41
|
} as const satisfies Record<string, RadarFeatureSpec>
|
|
39
42
|
|
|
40
43
|
export type RadarFeature = keyof typeof RADAR_FEATURES
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { detectTrafficSourcesAgain, fetchTrafficSources } from './traffic'
|
|
4
|
+
|
|
5
|
+
const found = { detected: [{ name: 'hubble', status: 'available' }], notDetected: [] }
|
|
6
|
+
const none = { detected: [], notDetected: [{ name: 'hubble', status: 'not_found' }] }
|
|
7
|
+
|
|
8
|
+
function serve(responses: Record<string, unknown>, delays: Record<string, number> = {}) {
|
|
9
|
+
const calls: string[] = []
|
|
10
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
|
11
|
+
const path = url.slice(url.indexOf('/traffic/'))
|
|
12
|
+
calls.push(path)
|
|
13
|
+
if (delays[path]) {
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
const timer = setTimeout(resolve, delays[path])
|
|
16
|
+
init?.signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('aborted', 'AbortError')) })
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
return new Response(JSON.stringify(responses[path]), { status: 200 })
|
|
20
|
+
}))
|
|
21
|
+
return calls
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
25
|
+
|
|
26
|
+
describe('fetchTrafficSources', () => {
|
|
27
|
+
it('reuses a recent detection that found a source', async () => {
|
|
28
|
+
const calls = serve({ '/traffic/sources?recent=1': found })
|
|
29
|
+
const sources = await fetchTrafficSources(new QueryClient(), false)
|
|
30
|
+
expect(sources).toEqual(found)
|
|
31
|
+
expect(calls).toEqual(['/traffic/sources?recent=1'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('detects afresh before showing a recent detection that found none', async () => {
|
|
35
|
+
const calls = serve({ '/traffic/sources?recent=1': none, '/traffic/sources': found })
|
|
36
|
+
const sources = await fetchTrafficSources(new QueryClient(), false)
|
|
37
|
+
expect(sources).toEqual(found)
|
|
38
|
+
expect(calls).toEqual(['/traffic/sources?recent=1', '/traffic/sources'])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('detectTrafficSourcesAgain', () => {
|
|
43
|
+
it('detects afresh even while a recent detection is being fetched', async () => {
|
|
44
|
+
const calls = serve({ '/traffic/sources?recent=1': found, '/traffic/sources': none }, { '/traffic/sources?recent=1': 50 })
|
|
45
|
+
const client = new QueryClient()
|
|
46
|
+
void client.fetchQuery({ queryKey: ['traffic-sources'], queryFn: ({ signal }) => fetchTrafficSources(client, false, signal) }).catch(() => {})
|
|
47
|
+
const sources = await detectTrafficSourcesAgain(client)
|
|
48
|
+
expect(sources).toEqual(none)
|
|
49
|
+
expect(calls).toContain('/traffic/sources')
|
|
50
|
+
// The abandoned recent detection must not land afterwards.
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, 80))
|
|
52
|
+
expect(client.getQueryData(['traffic-sources'])).toEqual(none)
|
|
53
|
+
expect(client.getQueryData(['traffic-sources', 'recent'])).toEqual(none)
|
|
54
|
+
})
|
|
55
|
+
})
|