@skyhook-io/radar-app 1.16.2 → 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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
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,
@@ -22,7 +23,7 @@ import type {
22
23
  } from '@skyhook-io/k8s-ui'
23
24
  import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
24
25
  import { showApiError, showApiSuccess } from '../components/ui/Toast'
25
- import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
26
+ import { useIsAuthEnabled, useNamespacedCapabilities } from '../contexts/CapabilitiesContext'
26
27
  import type {
27
28
  Topology,
28
29
  ClusterInfo,
@@ -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 };
@@ -2256,6 +2257,9 @@ export interface AuthMe {
2256
2257
  * When false, logout clears Radar's cookie but the proxy may re-auth
2257
2258
  * the same user on the next request. */
2258
2259
  proxyLogoutConfigured?: boolean;
2260
+ /** Connected and the user's access is known: true when their RBAC allows
2261
+ * reading no namespace at all. Absent when access couldn't be determined. */
2262
+ noNamespaceAccess?: boolean;
2259
2263
  }
2260
2264
 
2261
2265
  export function useAuthMe() {
@@ -2266,6 +2270,24 @@ export function useAuthMe() {
2266
2270
  });
2267
2271
  }
2268
2272
 
2273
+ /**
2274
+ * useNamespaceAccess asks the server to run namespace discovery for the caller,
2275
+ * so the answer never depends on whether the permission cache happens to hold
2276
+ * an entry. A user with no access is waiting on an admin; re-check every
2277
+ * minute so the banner clears once a binding lands.
2278
+ */
2279
+ export function useNamespaceAccess(enabled: boolean) {
2280
+ return useQuery<AuthMe>({
2281
+ queryKey: ["namespace-access"],
2282
+ queryFn: () => fetchJSON("/auth/me?check=namespaces"),
2283
+ enabled,
2284
+ staleTime: 60000,
2285
+ // Stop only on an explicit false: an absent field means discovery couldn't
2286
+ // answer this time, which says nothing about the user's access.
2287
+ refetchInterval: (query) => (query.state.data?.noNamespaceAccess === false ? false : 60000),
2288
+ });
2289
+ }
2290
+
2269
2291
  // Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
2270
2292
  // CloudRole.AtLeast — the frontend must agree with the backend on what
2271
2293
  // "member-or-higher" means; otherwise we'd hide a button the
@@ -2282,16 +2304,12 @@ const CLOUD_ROLE_RANK: Record<string, number> = {
2282
2304
  * present (OSS, OIDC, no role group, OR auth/me is still loading),
2283
2305
  * `canAtLeast` returns true — the gate is strictly additive for
2284
2306
  * Cloud-attributed users, mirroring the backend's `requireCloudRole`
2285
- * semantics. Use for passive content gating (panels, sections); use
2286
- * `useCanHelmAct` (or similar) for *click-prone* surfaces where you
2287
- * need fail-closed behavior during the auth/me round-trip to prevent
2288
- * a viewer from clicking through during the loading window.
2307
+ * semantics. It gates Radar's own features (settings, integrations), never
2308
+ * operations that run as the user, which Kubernetes RBAC decides.
2289
2309
  *
2290
- * Why optimistic during load: the gated empty state ("Your role can't
2291
- * view…") rendered briefly to OSS / kubectl-plugin users before
2292
- * auth/me resolves is a worse regression than a Cloud viewer seeing
2293
- * a content tab populate for a tick before being gated out. Click-
2294
- * prevention belongs in the action-button hook, not here.
2310
+ * Why optimistic during load: a gated empty state rendered briefly to
2311
+ * OSS / kubectl-plugin users before auth/me resolves is a worse regression
2312
+ * than a Cloud viewer seeing content for a tick before being gated out.
2295
2313
  */
2296
2314
  export function useCloudRole() {
2297
2315
  const { data, isLoading } = useAuthMe();
@@ -2308,42 +2326,35 @@ export function useCloudRole() {
2308
2326
  }
2309
2327
 
2310
2328
  /**
2311
- * useCanHelmAct combines the K8s capability gate (rbac.helm=true) and
2312
- * the Cloud role gate (member+) into a single answer for any Helm
2313
- * write or sensitive-read button. Returns { allowed, reason } so the
2314
- * tooltip can explain which gate failed.
2315
- *
2316
- * Cloud role check runs FIRST so the message is actionable for Cloud
2317
- * users — telling them "Helm write permissions required" is wrong if
2318
- * the chart is fine and the actual gate is their viewer role.
2329
+ * useCanHelmAct answers whether Helm write buttons are usable for a release in
2330
+ * `namespace`. With auth on, Helm runs as the signed-in user, so the check is
2331
+ * the user's own Kubernetes RBAC in that namespace (Helm writes each release as
2332
+ * a Secret there); the Cloud role says nothing about cluster access when IdP
2333
+ * groups grant it. Without auth, Helm runs as Radar's ServiceAccount, which
2334
+ * needs the chart's rbac.helm=true.
2319
2335
  */
2320
- export function useCanHelmAct(): { allowed: boolean; reason?: string } {
2321
- const helmWrite = useCanHelmWrite();
2322
- const { role, canAtLeast, isLoading } = useCloudRole();
2323
- // Fail-closed for action buttons during the auth/me round-trip:
2324
- // a Cloud viewer who clicks during loading would otherwise fire a
2325
- // real request that gets 403'd. For OSS / kubectl-plugin the
2326
- // round-trip is sub-ms so this is imperceptible; for Cloud it
2327
- // prevents the click-through window. Distinct from useCloudRole's
2328
- // canAtLeast (which is optimistic during loading) because passive
2329
- // content gates don't have a click-handler to misfire.
2330
- if (isLoading) {
2331
- return { allowed: false, reason: "Loading permissions…" };
2336
+ export function useCanHelmAct(namespace?: string): { allowed: boolean; reason?: string } {
2337
+ const { canHelmWrite, helmWriteUnknown } = useNamespacedCapabilities(namespace);
2338
+ const authEnabled = useIsAuthEnabled();
2339
+ // Without a namespace answer (still loading, or the check failed) the global
2340
+ // value only says the user can create Secrets somewhere, which would enable
2341
+ // actions the server then refuses.
2342
+ if (authEnabled && helmWriteUnknown) {
2343
+ return { allowed: false, reason: `Couldn't confirm your permissions in ${namespace} yet.` };
2332
2344
  }
2333
- if (!canAtLeast("member")) {
2345
+ if (canHelmWrite) return { allowed: true };
2346
+ if (authEnabled) {
2334
2347
  return {
2335
2348
  allowed: false,
2336
- reason: `Your Radar Cloud role (${role ?? "unknown"}) cannot run Helm operations. Ask a member or owner.`,
2349
+ reason: namespace
2350
+ ? `Your Kubernetes permissions don't allow managing Helm releases in ${namespace} (creating Secrets there).`
2351
+ : "Your Kubernetes permissions don't allow managing Helm releases.",
2337
2352
  };
2338
2353
  }
2339
- if (!helmWrite) {
2340
- return {
2341
- allowed: false,
2342
- reason:
2343
- "Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.",
2344
- };
2345
- }
2346
- return { allowed: true };
2354
+ return {
2355
+ allowed: false,
2356
+ reason: "Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.",
2357
+ };
2347
2358
  }
2348
2359
 
2349
2360
  // Namespaces
@@ -2498,6 +2509,24 @@ export function useGitOpsInsights(
2498
2509
  });
2499
2510
  }
2500
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
+
2501
2530
  // Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
2502
2531
  // ns/name identify the Application; the ref identifies the managed resource.
2503
2532
  // Fetched on demand — the caller mounts this only when the user opens "Full
@@ -2587,18 +2616,55 @@ export function fetchResourceWithRelationships<T>(
2587
2616
  );
2588
2617
  }
2589
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).
2590
2656
  export function useResource<T>(
2591
2657
  kind: string,
2592
2658
  namespace: string,
2593
2659
  name: string,
2594
2660
  group?: string,
2595
- options?: { enabled?: boolean; refetchInterval?: number | false },
2661
+ options?: { enabled?: boolean; refetchInterval?: number | false; cacheScope?: string },
2596
2662
  ) {
2597
2663
  const query = useQuery<ResourceWithRelationships<T>>({
2598
- queryKey: ["resource", kind, namespace, name, group],
2664
+ queryKey: ["resource", kind, namespace, name, group, ...(options?.cacheScope ? [options.cacheScope] : [])],
2599
2665
  queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2600
2666
  enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
2601
- refetchInterval: options?.refetchInterval,
2667
+ refetchInterval: (query) => goneRecheckInterval(query, options?.refetchInterval),
2602
2668
  // Kind still completing its initial sync: stay in loading and poll until
2603
2669
  // it becomes readable instead of erroring out (deep links during startup).
2604
2670
  retry: (failureCount, error) => {
@@ -2626,11 +2692,13 @@ export function useResourceWithRelationships<T>(
2626
2692
  namespace: string,
2627
2693
  name: string,
2628
2694
  group?: string,
2695
+ options?: { cacheScope?: string },
2629
2696
  ) {
2630
2697
  return useQuery<ResourceWithRelationships<T>>({
2631
- queryKey: ["resource", kind, namespace, name, group],
2698
+ queryKey: ["resource", kind, namespace, name, group, ...(options?.cacheScope ? [options.cacheScope] : [])],
2632
2699
  queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2633
2700
  enabled: Boolean(kind && name),
2701
+ refetchInterval: (query) => goneRecheckInterval(query, undefined),
2634
2702
  // Deep-linked detail views can mount while the kind's informer is still
2635
2703
  // completing its initial sync: keep polling instead of erroring out.
2636
2704
  retry: (failureCount, error) => {
@@ -2648,7 +2716,7 @@ export function useResources<T>(
2648
2716
  kind: string,
2649
2717
  namespace?: string,
2650
2718
  group?: string,
2651
- options?: { enabled?: boolean; refetchInterval?: number | false },
2719
+ options?: { enabled?: boolean; refetchInterval?: number | false; cacheScope?: string },
2652
2720
  ) {
2653
2721
  const params = new URLSearchParams();
2654
2722
  if (namespace) params.set("namespace", namespace);
@@ -2656,7 +2724,7 @@ export function useResources<T>(
2656
2724
  const queryString = params.toString();
2657
2725
 
2658
2726
  return useQuery<T[]>({
2659
- queryKey: ["resources", kind, group, namespace],
2727
+ queryKey: ["resources", kind, group, namespace, ...(options?.cacheScope ? [options.cacheScope] : [])],
2660
2728
  queryFn: () =>
2661
2729
  fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ""}`),
2662
2730
  enabled: (options?.enabled ?? true) && Boolean(kind),
@@ -2971,6 +3039,38 @@ export function useWorkloadHistory(kind: string, namespace: string, name: string
2971
3039
  return query;
2972
3040
  }
2973
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
+
2974
3074
  export function fetchWorkloadHistoryPage(kind: string, namespace: string, name: string, group: string | undefined, beforeSeq: number): Promise<WorkloadHistoryPage> {
2975
3075
  return fetchJSON(workloadHistoryPath(kind, namespace, name, group, beforeSeq));
2976
3076
  }
@@ -4092,7 +4192,7 @@ export function useUpdateResource() {
4092
4192
  });
4093
4193
  if (!response.ok) {
4094
4194
  const error = await readErrorBody(response);
4095
- const failure = new Error(error.error || `HTTP ${response.status}`);
4195
+ const failure = new ApiError(error.error || `HTTP ${response.status}`, response.status, error);
4096
4196
  // A reviewed apply's failure is shown in the review itself (the
4097
4197
  // changed-after-review notice, or the error), so it gets no toast.
4098
4198
  if (reviewedResourceVersion !== undefined || reviewedContext !== undefined) {
@@ -5322,6 +5422,23 @@ export function useDrainPlan() {
5322
5422
  });
5323
5423
  }
5324
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
+
5325
5442
  export function useDrainNode() {
5326
5443
  const queryClient = useQueryClient();
5327
5444
 
@@ -5457,14 +5574,10 @@ export function useHelmRelease(
5457
5574
  }
5458
5575
 
5459
5576
  // Get manifest for a Helm release (optionally at a specific revision).
5460
- // `enabled` lets callers skip the query when the user's Cloud role
5461
- // would 403 the read — saves a round-trip and avoids a transient
5462
- // "error" state that the role-gated empty panel doesn't need.
5463
5577
  export function useHelmManifest(
5464
5578
  namespace: string,
5465
5579
  name: string,
5466
5580
  revision?: number,
5467
- enabled = true,
5468
5581
  ) {
5469
5582
  const params = revision ? `?revision=${revision}` : "";
5470
5583
  return useQuery<string>({
@@ -5479,12 +5592,12 @@ export function useHelmManifest(
5479
5592
  }
5480
5593
  return response.text();
5481
5594
  },
5482
- enabled: Boolean(namespace && name && enabled),
5595
+ enabled: Boolean(namespace && name),
5483
5596
  staleTime: 60000, // 1 minute
5484
5597
  });
5485
5598
  }
5486
5599
 
5487
- // Get values for a Helm release. `enabled` see useHelmManifest.
5600
+ // Get values for a Helm release.
5488
5601
  export function useHelmValues(
5489
5602
  namespace: string,
5490
5603
  name: string,
@@ -5505,7 +5618,7 @@ export function useHelmValues(
5505
5618
  });
5506
5619
  }
5507
5620
 
5508
- // Get diff between two revisions. `enabled` see useHelmManifest.
5621
+ // Get diff between two revisions.
5509
5622
  export function useHelmManifestDiff(
5510
5623
  namespace: string,
5511
5624
  name: string,
@@ -0,0 +1,95 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import type { CNPGWorkspaceResponse, TimelineEvent } from '@skyhook-io/k8s-ui'
3
+ import { fetchJSON } from './client'
4
+
5
+ // /api/cnpg/workspace
6
+ //
7
+ // Every CloudNativePG object the caller may read, with per-kind coverage, the
8
+ // CNPG issues on them and the no-schedule audit finding. One query feeds the
9
+ // workspace screens, the Resources sidebar counts and the composed summaries,
10
+ // so they can never disagree about what they count.
11
+ export function useCNPGWorkspace(namespaces: string[], options?: { enabled?: boolean }) {
12
+ const ns = [...namespaces].sort().join(',')
13
+ return useQuery<CNPGWorkspaceResponse>({
14
+ queryKey: ['cnpg', 'workspace', ns],
15
+ queryFn: ({ signal }) => fetchJSON<CNPGWorkspaceResponse>(`/cnpg/workspace${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal),
16
+ enabled: options?.enabled ?? true,
17
+ staleTime: 10_000,
18
+ refetchInterval: 30_000,
19
+ placeholderData: (prev) => prev,
20
+ })
21
+ }
22
+
23
+ export interface CNPGOperatorCoverage {
24
+ state: 'full' | 'partial' | 'denied' | 'syncing' | 'error'
25
+ deniedNamespaces?: string[]
26
+ }
27
+
28
+ export interface CNPGOperatorComponent {
29
+ role: 'operator' | 'plugin'
30
+ pluginName?: string
31
+ namespace: string
32
+ deployment: string
33
+ image?: string
34
+ version?: string
35
+ readyReplicas: number | null
36
+ replicas: number | null
37
+ }
38
+
39
+ export interface CNPGOperatorConfig {
40
+ kind: 'ConfigMap' | 'Secret'
41
+ namespace: string
42
+ name: string
43
+ purpose: 'operator' | 'monitoring'
44
+ exists?: boolean | null
45
+ readable?: boolean
46
+ reason?: string
47
+ data?: Record<string, string>
48
+ }
49
+
50
+ export interface CNPGOperatorResponse {
51
+ coverage: { deployments: CNPGOperatorCoverage; services: CNPGOperatorCoverage }
52
+ components: CNPGOperatorComponent[]
53
+ config: CNPGOperatorConfig[]
54
+ }
55
+
56
+ // /api/cnpg/operator
57
+ //
58
+ // Operator and plugin workloads plus where the operator's configuration lives.
59
+ // Deliberately not filtered by the namespace view filter: the operator runs in
60
+ // its own namespace, which users rarely have selected.
61
+ export function useCNPGOperator(options?: { enabled?: boolean }) {
62
+ return useQuery<CNPGOperatorResponse>({
63
+ queryKey: ['cnpg', 'operator'],
64
+ queryFn: ({ signal }) => fetchJSON<CNPGOperatorResponse>('/cnpg/operator', signal),
65
+ enabled: options?.enabled ?? true,
66
+ staleTime: 30_000,
67
+ refetchInterval: 60_000,
68
+ })
69
+ }
70
+
71
+ export interface CNPGClusterActivityResponse {
72
+ events: TimelineEvent[]
73
+ oldest: string | null
74
+ attributionSince: string | null
75
+ truncated: boolean
76
+ }
77
+
78
+ // /api/cnpg/clusters/{ns}/{name}/activity
79
+ //
80
+ // The Cluster's history together with its instance Pods and every CNPG object
81
+ // attributed to it, including ones since deleted.
82
+ export function useCNPGClusterActivity(namespace: string, name: string, sinceHours = 24) {
83
+ return useQuery<CNPGClusterActivityResponse>({
84
+ queryKey: ['cnpg', 'activity', namespace, name, sinceHours],
85
+ queryFn: ({ signal }) => {
86
+ const since = new Date(Date.now() - sinceHours * 3600_000).toISOString()
87
+ return fetchJSON<CNPGClusterActivityResponse>(
88
+ `/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/activity?since=${encodeURIComponent(since)}&limit=500`,
89
+ signal,
90
+ )
91
+ },
92
+ staleTime: 10_000,
93
+ refetchInterval: 30_000,
94
+ })
95
+ }
@@ -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
@@ -423,7 +423,7 @@ export async function fetchRetainedDelta(
423
423
  // Mirrors the Go store's TimelineEvent.IsManaged (pkg/timeline/types.go):
424
424
  // a resource managed by another — owned, or one of the churn kinds. Keep the
425
425
  // two predicates in lockstep.
426
- function isManagedTimelineEvent(e: TimelineEvent): boolean {
426
+ export function isManagedTimelineEvent(e: TimelineEvent): boolean {
427
427
  return e.owner != null || e.kind === 'ReplicaSet' || e.kind === 'Pod' || e.kind === 'Event'
428
428
  }
429
429
 
@@ -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
+ })