@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.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +14 -7
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +115 -8
  6. package/src/api/diagnose.test.ts +40 -0
  7. package/src/api/diagnose.ts +12 -1
  8. package/src/api/radarFeatures.render.test.tsx +19 -1
  9. package/src/api/radarFeatures.ts +3 -0
  10. package/src/api/traffic.sources.test.ts +55 -0
  11. package/src/api/traffic.ts +173 -14
  12. package/src/components/applications/ApplicationsView.tsx +3 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  14. package/src/components/capacity/CapacityView.test.tsx +31 -1
  15. package/src/components/capacity/shared.tsx +12 -21
  16. package/src/components/cnpg/CNPGProtection.tsx +2 -2
  17. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  18. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  19. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  20. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  21. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  22. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  23. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  24. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  25. package/src/components/diagnose/InvestigationView.tsx +39 -2
  26. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  27. package/src/components/diagnose/agentCatalog.ts +2 -2
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  29. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  30. package/src/components/execution/BatchExecutionView.tsx +11 -11
  31. package/src/components/helm/ChartBrowser.tsx +3 -3
  32. package/src/components/helm/RevisionHistory.tsx +3 -2
  33. package/src/components/home/ClusterHealthCard.tsx +7 -0
  34. package/src/components/home/mcpToolCatalog.ts +1 -1
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  36. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  37. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  39. package/src/components/settings/SettingsDialog.tsx +122 -19
  40. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  41. package/src/components/timeline/TimelineList.tsx +3 -0
  42. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  43. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  44. package/src/components/timeline/TimelineView.tsx +7 -0
  45. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  46. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  47. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  48. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  49. package/src/components/traffic/TrafficGraph.tsx +109 -26
  50. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  51. package/src/components/traffic/TrafficView.tsx +582 -149
  52. package/src/components/traffic/trafficFilters.test.ts +398 -1
  53. package/src/components/traffic/trafficFilters.ts +408 -0
  54. package/src/components/workload/WorkloadView.test.ts +2 -1
  55. package/src/components/workload/WorkloadView.tsx +135 -290
  56. package/src/context/TimelineCustomization.tsx +15 -0
  57. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  58. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  59. 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.16.3",
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.16.3",
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
- queryClient.invalidateQueries({ queryKey: ['resource', k] }) // open detail drawer stays live
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
- <DiagnoseCustomizationProvider
270
- value={renderDiagnoseAction ?? defaultDiagnoseAction}
271
- consentCopy={diagnoseConsent}
272
- renderRunActions={renderInvestigationRunActions}
273
- renderRefusalAction={renderInvestigationRefusalAction}
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
- <App
281
- manageDocumentTitle={manageDocumentTitle}
282
- documentTitleSuffix={documentTitleSuffix}
283
- onClusterLoadStateChange={onClusterLoadStateChange}
284
- />
285
- </DiagnoseProvider>
286
- </DiagnoseCustomizationProvider>
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 Error(error.error || `HTTP ${response.status}`);
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
 
@@ -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
+ });
@@ -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
- return res.json();
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 />)
@@ -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
+ })