@skyhook-io/radar-app 1.10.0 → 1.12.2

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 (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. package/src/utils/wails-clipboard.ts +127 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.10.0",
3
+ "version": "1.12.2",
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",
@@ -30,18 +30,18 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@fontsource-variable/dm-sans": "^5.2.8",
33
- "@fontsource/dm-mono": "^5.2.7",
33
+ "@fontsource/dm-mono": "^5.3.0",
34
34
  "@monaco-editor/react": "^4.7.0",
35
35
  "diff": "^9.0.0",
36
36
  "monaco-editor": "0.55.1",
37
37
  "react-markdown": "^10.1.0",
38
38
  "react-virtuoso": "^4.18.11",
39
39
  "remark-gfm": "^4.0.1",
40
- "shiki": "^4.2.0",
40
+ "shiki": "^4.4.2",
41
41
  "yaml": "^2.9.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@skyhook-io/k8s-ui": ">=1.12.0",
44
+ "@skyhook-io/k8s-ui": ">=1.14.0",
45
45
  "@tanstack/react-query": ">=5",
46
46
  "@xyflow/react": ">=12.0.0",
47
47
  "clsx": ">=2",
@@ -59,10 +59,10 @@
59
59
  "@tailwindcss/typography": "^0.5.20",
60
60
  "@tailwindcss/vite": "^4.3.3",
61
61
  "@tanstack/react-query": "^5.101.4",
62
- "@types/node": "^26.1.1",
62
+ "@types/node": "^26.2.0",
63
63
  "@types/react": "^19.2.18",
64
64
  "@types/react-dom": "^19.2.3",
65
- "@vitejs/plugin-react": "^6.0.4",
65
+ "@vitejs/plugin-react": "^6.1.0",
66
66
  "@xyflow/react": "^12.11.2",
67
67
  "clsx": "^2.1.1",
68
68
  "elkjs": "^0.11.1",
@@ -70,8 +70,8 @@
70
70
  "eslint-plugin-react-hooks": "^7.1.1",
71
71
  "eslint-plugin-react-refresh": "^0.5.3",
72
72
  "globals": "^17.9.0",
73
- "lucide-react": "^1.28.0",
74
- "postcss": "^8.5.25",
73
+ "lucide-react": "^1.33.0",
74
+ "postcss": "^8.5.26",
75
75
  "prettier": "^3.9.5",
76
76
  "react": "^19.2.8",
77
77
  "react-dom": "^19.2.8",
@@ -79,8 +79,8 @@
79
79
  "tailwind-merge": "^3.6.0",
80
80
  "tailwindcss": "^4.3.3",
81
81
  "typescript": "^6.0.2",
82
- "typescript-eslint": "^8.62.0",
83
- "vite": "^8.1.5"
82
+ "typescript-eslint": "^8.67.0",
83
+ "vite": "^8.2.1"
84
84
  },
85
85
  "sideEffects": [
86
86
  "*.css"
package/src/App.tsx CHANGED
@@ -65,6 +65,7 @@ import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNam
65
65
  import { SettingsDialog, type SettingsSectionId } from './components/settings/SettingsDialog'
66
66
  import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
67
67
  import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
68
+ import { findSelectedTopologyNode } from './utils/topology-selection'
68
69
  import { type OmnibarHandle } from './components/ui/Omnibar'
69
70
  import { RadarOmnibar } from './components/ui/RadarOmnibar'
70
71
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
@@ -1605,12 +1606,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1605
1606
  // collisions — rebuilding the string can't match those reliably.
1606
1607
  const selectedNodeId = useMemo(() => {
1607
1608
  if (!selectedResource) return undefined
1608
- const ns = selectedResource.namespace || ''
1609
- const match = topology?.nodes.find(n =>
1610
- ((n.data.namespace as string) || '') === ns &&
1611
- n.name === selectedResource.name &&
1612
- (kindToPlural(n.kind) === selectedResource.kind || n.kind === selectedResource.kind)
1613
- )
1609
+ const match = findSelectedTopologyNode(topology, selectedResource)
1614
1610
  return match?.id
1615
1611
  }, [selectedResource, topology])
1616
1612
 
@@ -1668,8 +1664,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1668
1664
  {/* `relative` makes this column the containing block for the absolute
1669
1665
  overlays it hosts (BottomDock, expanded ResourceDetailDrawer) so they
1670
1666
  span the content area AFTER the rail rather than the full viewport
1671
- under it. `fixed` splashes (connecting/switching) are unaffected. */}
1672
- <div className="relative flex flex-col flex-1 min-w-0 h-full">
1667
+ under it. Horizontal clipping keeps translated-offscreen drawers from
1668
+ contributing document overflow. `fixed` splashes are unaffected. */}
1669
+ <div className="relative flex flex-col flex-1 min-w-0 h-full overflow-x-clip">
1673
1670
  {/* Header — suppressed in chromeless embed; the host owns the chrome.
1674
1671
  @container: the header's responsive layout keys off its OWN width (≈ viewport
1675
1672
  − nav rail), not the viewport, so it collapses gracefully on narrow windows.
package/src/api/client.ts CHANGED
@@ -373,11 +373,6 @@ export interface DashboardChange {
373
373
  timestamp: string;
374
374
  }
375
375
 
376
- export interface DashboardTopologySummary {
377
- nodeCount: number;
378
- edgeCount: number;
379
- }
380
-
381
376
  export interface DashboardTopFlow {
382
377
  src: string;
383
378
  dst: string;
@@ -486,6 +481,12 @@ export interface UpgradeReadinessResponse {
486
481
  currentVersion: string
487
482
  targetVersion: string
488
483
  reviewedThrough: string
484
+ // When the underlying scan ran. The server memoizes scans (~60s), so a
485
+ // response can be older than the fetch that returned it — surface this,
486
+ // not dataUpdatedAt, as the scan time.
487
+ observedAt: string
488
+ scanId: string
489
+ fromCache?: boolean
489
490
  verdict: UpgradeReadinessVerdict
490
491
  summary: { blocked: number; warnings: number; reviews: number; passed: number; unknown: number; notApplicable: number; findings: number }
491
492
  checks: UpgradeReadinessCheck[]
@@ -508,7 +509,9 @@ export interface DashboardCertificateHealth {
508
509
 
509
510
  export interface DashboardNetworkPolicyCoverage {
510
511
  totalPolicies: number;
512
+ stagedPolicies?: number;
511
513
  coveredWorkloads: number;
514
+ coveredWorkloadsIfStaged?: number;
512
515
  totalWorkloads: number;
513
516
  }
514
517
 
@@ -543,7 +546,6 @@ export interface DashboardResponse {
543
546
  problems: DashboardProblem[]
544
547
  resourceCounts: DashboardResourceCounts
545
548
  recentChanges: DashboardChange[]
546
- topologySummary: DashboardTopologySummary
547
549
  trafficSummary: DashboardTrafficSummary | null
548
550
  metrics: DashboardMetrics | null
549
551
  metricsServerAvailable: boolean
@@ -594,15 +596,28 @@ export function useAudit(namespaces: string[] = []) {
594
596
  }
595
597
 
596
598
  export function useUpgradeReadiness(target?: string) {
597
- const params = new URLSearchParams()
598
- if (target) params.set('target', target)
599
- const query = params.toString()
600
- return useQuery<UpgradeReadinessResponse>({
599
+ // The server memoizes scans per caller (~60s). Ordinary and background
600
+ // refetches read that memo; only the explicit manual refresh passes
601
+ // refresh=true to force a fresh live scan (e.g. after fixing a finding).
602
+ const forceRefresh = useRef(false)
603
+ const query = useQuery<UpgradeReadinessResponse>({
601
604
  queryKey: ['upgrade-readiness', target ?? 'next'],
602
- queryFn: ({ signal }) => fetchJSON(`/upgrade-readiness${query ? `?${query}` : ''}`, signal),
605
+ queryFn: ({ signal }) => {
606
+ const params = new URLSearchParams()
607
+ if (target) params.set('target', target)
608
+ if (forceRefresh.current) params.set('refresh', 'true')
609
+ forceRefresh.current = false
610
+ const qs = params.toString()
611
+ return fetchJSON(`/upgrade-readiness${qs ? `?${qs}` : ''}`, signal)
612
+ },
603
613
  staleTime: 30000,
604
614
  placeholderData: (previous) => previous,
605
615
  })
616
+ const refreshScan = () => {
617
+ forceRefresh.current = true
618
+ return query.refetch()
619
+ }
620
+ return { ...query, refreshScan }
606
621
  }
607
622
 
608
623
  // Live cluster Issues — the grouped triage queue (radar's /api/issues =
@@ -941,6 +956,7 @@ export interface OpenCostWorkloadCost {
941
956
  export interface OpenCostWorkloadResponse {
942
957
  available: boolean;
943
958
  reason?: CostUnavailableReason;
959
+ currency?: string;
944
960
  namespace: string;
945
961
  workloads: OpenCostWorkloadCost[];
946
962
  }
@@ -968,6 +984,7 @@ export function useOpenCostWorkloads(
968
984
  export interface OpenCostWorkloadDetailResponse {
969
985
  available: boolean;
970
986
  reason?: CostUnavailableReason;
987
+ currency?: string;
971
988
  namespace: string;
972
989
  kind: string;
973
990
  name: string;
@@ -1012,6 +1029,7 @@ export interface OpenCostTrendSeries {
1012
1029
  export interface OpenCostTrendResponse {
1013
1030
  available: boolean;
1014
1031
  reason?: CostUnavailableReason;
1032
+ currency?: string;
1015
1033
  range: string;
1016
1034
  series?: OpenCostTrendSeries[];
1017
1035
  }
@@ -1033,6 +1051,7 @@ export function useOpenCostTrend(range_: CostTimeRange = "24h") {
1033
1051
  export interface OpenCostWorkloadTrendResponse {
1034
1052
  available: boolean;
1035
1053
  reason?: CostUnavailableReason;
1054
+ currency?: string;
1036
1055
  namespace: string;
1037
1056
  kind: string;
1038
1057
  name: string;
@@ -1105,6 +1124,7 @@ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWork
1105
1124
  export interface OpenCostApplicationCostResponse {
1106
1125
  available: boolean;
1107
1126
  reason?: CostUnavailableReason;
1127
+ currency?: string;
1108
1128
  partial?: boolean;
1109
1129
  totals: OpenCostApplicationCostTotals;
1110
1130
  coverage: OpenCostApplicationCostCoverage;
@@ -1119,6 +1139,7 @@ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationW
1119
1139
  export interface OpenCostApplicationCostTrendResponse {
1120
1140
  available: boolean;
1121
1141
  reason?: CostUnavailableReason;
1142
+ currency?: string;
1122
1143
  range: string;
1123
1144
  partial?: boolean;
1124
1145
  windowTotalCost?: number;
@@ -1210,6 +1231,7 @@ export interface OpenCostNodeCost {
1210
1231
  export interface OpenCostNodeResponse {
1211
1232
  available: boolean;
1212
1233
  reason?: CostUnavailableReason;
1234
+ currency?: string;
1213
1235
  nodes?: OpenCostNodeCost[];
1214
1236
  }
1215
1237
 
@@ -3108,6 +3130,7 @@ export interface ArgoStatus {
3108
3130
  configured: boolean;
3109
3131
  connected: boolean;
3110
3132
  address?: string;
3133
+ reason?: string;
3111
3134
  }
3112
3135
 
3113
3136
  export function useArgoStatus(enabled = true) {
@@ -4388,6 +4411,10 @@ export interface WorkloadRevision {
4388
4411
  isCurrent: boolean;
4389
4412
  replicas: number;
4390
4413
  template?: string; // Pod template spec as YAML (for revision diff)
4414
+ // Argo Rollouts only: mid-canary isCurrent (rolling out) and isStable
4415
+ // (serving traffic, what an abort reverts to) are different revisions.
4416
+ isStable?: boolean;
4417
+ podHash?: string;
4391
4418
  }
4392
4419
 
4393
4420
  export function useWorkloadRevisions(
@@ -4463,6 +4490,119 @@ export function useRollbackWorkload() {
4463
4490
  });
4464
4491
  }
4465
4492
 
4493
+ // ============================================================================
4494
+ // Argo Rollouts progressive-delivery control plane
4495
+ // ============================================================================
4496
+
4497
+ export type RolloutAction =
4498
+ | "abort"
4499
+ | "retry"
4500
+ | "promote"
4501
+ | "promote-full"
4502
+ | "skip-step";
4503
+
4504
+ export interface RolloutCapabilities {
4505
+ abort: boolean;
4506
+ retry: boolean;
4507
+ promote: boolean;
4508
+ promoteFull: boolean;
4509
+ skipStep: boolean;
4510
+ rollback: boolean;
4511
+ restart: boolean;
4512
+ strategy: string;
4513
+ terminating: boolean;
4514
+ }
4515
+
4516
+ export function useRolloutCapabilities(
4517
+ namespace: string,
4518
+ name: string,
4519
+ enabled = true,
4520
+ ) {
4521
+ return useQuery<RolloutCapabilities>({
4522
+ queryKey: ["rollout-capabilities", namespace, name],
4523
+ queryFn: () => fetchJSON(`/rollouts/${namespace}/${name}/capabilities`),
4524
+ enabled: Boolean(namespace && name && enabled),
4525
+ });
4526
+ }
4527
+
4528
+ const ROLLOUT_ACTION_MESSAGES: Record<
4529
+ RolloutAction,
4530
+ { errorMessage: string; successMessage: string }
4531
+ > = {
4532
+ abort: {
4533
+ errorMessage: "Failed to abort rollout",
4534
+ successMessage: "Rollout aborted — traffic reverted to the stable version",
4535
+ },
4536
+ retry: {
4537
+ errorMessage: "Failed to retry rollout",
4538
+ successMessage: "Rollout retried",
4539
+ },
4540
+ promote: {
4541
+ errorMessage: "Failed to promote rollout",
4542
+ successMessage: "Rollout promoted",
4543
+ },
4544
+ "promote-full": {
4545
+ errorMessage: "Failed to promote rollout",
4546
+ successMessage: "Rollout promoted to full — remaining steps skipped",
4547
+ },
4548
+ "skip-step": {
4549
+ errorMessage: "Failed to skip step",
4550
+ successMessage: "Skipped to the next step",
4551
+ },
4552
+ };
4553
+
4554
+ export function useRolloutAction() {
4555
+ const queryClient = useQueryClient();
4556
+ return useMutation({
4557
+ mutationFn: async ({
4558
+ action,
4559
+ namespace,
4560
+ name,
4561
+ }: {
4562
+ action: RolloutAction;
4563
+ namespace: string;
4564
+ name: string;
4565
+ }) => {
4566
+ const response = await apiFetch(
4567
+ `${getApiBase()}/rollouts/${namespace}/${name}/${action}`,
4568
+ { method: "POST" },
4569
+ );
4570
+ if (!response.ok) {
4571
+ const error = await response
4572
+ .json()
4573
+ .catch(() => ({ error: "Unknown error" }));
4574
+ throw new Error(error.error || `HTTP ${response.status}`);
4575
+ }
4576
+ return response.json();
4577
+ },
4578
+ // meta is static, so per-action success wording goes through onSuccess.
4579
+ meta: { errorMessage: "Rollout action failed" },
4580
+ onSuccess: (_, variables) => {
4581
+ showApiSuccess(ROLLOUT_ACTION_MESSAGES[variables.action].successMessage);
4582
+ queryClient.invalidateQueries({ queryKey: ["resources", "rollouts"] });
4583
+ queryClient.invalidateQueries({
4584
+ queryKey: ["resource", "rollouts", variables.namespace, variables.name],
4585
+ });
4586
+ queryClient.invalidateQueries({
4587
+ queryKey: [
4588
+ "rollout-capabilities",
4589
+ variables.namespace,
4590
+ variables.name,
4591
+ ],
4592
+ });
4593
+ queryClient.invalidateQueries({
4594
+ queryKey: [
4595
+ "workload-revisions",
4596
+ "rollouts",
4597
+ variables.namespace,
4598
+ variables.name,
4599
+ ],
4600
+ });
4601
+ queryClient.invalidateQueries({ queryKey: ["topology"] });
4602
+ },
4603
+ });
4604
+ }
4605
+
4466
4606
  // ============================================================================
4467
4607
  // Node operations (cordon, uncordon, drain)
4468
4608
  // ============================================================================
@@ -6213,6 +6353,14 @@ export interface DiagErrorEntry {
6213
6353
  level: string;
6214
6354
  }
6215
6355
 
6356
+ export interface DiagEnvVar {
6357
+ key: string;
6358
+ value: string;
6359
+ /** Whether the variable exists at all. An empty value with set=true is a
6360
+ * distinct state — it suppresses the desktop app's own WebKit defaults. */
6361
+ set: boolean;
6362
+ }
6363
+
6216
6364
  export type DiagSyncPhase =
6217
6365
  "not_started" | "syncing_critical" | "syncing_deferred" | "complete";
6218
6366
 
@@ -6284,10 +6432,13 @@ export interface DiagnosticsSnapshot {
6284
6432
  errorType?: string;
6285
6433
  };
6286
6434
  kubeconfig?: {
6287
- mode: "" | "in-cluster" | "single" | "multi-env" | "multi-dir";
6435
+ mode: "" | "in-cluster" | "single" | "multi-env" | "multi-dir" | "multi-source";
6288
6436
  fileCount: number;
6437
+ directoryFileCount: number;
6289
6438
  contextCount: number;
6290
6439
  enrichedFromShell: boolean;
6440
+ kubeconfigEnvIgnored: boolean;
6441
+ kubeconfigEnvIgnoredReason: string;
6291
6442
  currentContextUsesExec: boolean;
6292
6443
  execPluginsPresent?: string[];
6293
6444
  execPluginsMissing?: string[];
@@ -6362,6 +6513,17 @@ export interface DiagnosticsSnapshot {
6362
6513
  connectedClients: number;
6363
6514
  };
6364
6515
  perf?: DiagPerfSnapshot;
6516
+ /** Desktop app on Linux only — the host rendering environment. */
6517
+ desktop?: {
6518
+ sessionType?: string;
6519
+ desktopEnvironment?: string;
6520
+ displayServer?: string;
6521
+ /** Every override key, including ones the host never set. */
6522
+ renderOverrides?: DiagEnvVar[];
6523
+ sandbox?: DiagEnvVar[];
6524
+ webkitLibrary?: string;
6525
+ gpuPolicy?: string;
6526
+ };
6365
6527
  runtime?: {
6366
6528
  heapMB: number;
6367
6529
  heapObjectsK: number;
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isSameCoverageSubject } from './policy'
3
+
4
+ /**
5
+ * The drawer reuses one query observer as it opens different policies, so
6
+ * keeping the previous result unconditionally renders policy A's resources and
7
+ * counts under policy B's name — with no loading state to suggest otherwise.
8
+ * Only a changed `limit` may reuse it.
9
+ */
10
+ const key = (policy: string, ns: string, limit: number, view: string) =>
11
+ ['policy', 'coverage', policy, ns, limit, view] as const
12
+
13
+ describe('reusing a cached coverage result', () => {
14
+ it('reuses it when only the limit changed', () => {
15
+ expect(isSameCoverageSubject(key('require-limits', '', 0, ''), 'require-limits', '', '')).toBe(true)
16
+ expect(isSameCoverageSubject(key('require-limits', '', 5000, ''), 'require-limits', '', '')).toBe(true)
17
+ })
18
+
19
+ it('refuses it for a different policy', () => {
20
+ expect(isSameCoverageSubject(key('policy-a', '', 0, ''), 'policy-b', '', '')).toBe(false)
21
+ })
22
+
23
+ // Kyverno allows a namespaced Policy and a ClusterPolicy to share a name, so
24
+ // the namespace is what separates two same-named subjects.
25
+ it('refuses it for the same name in a different namespace', () => {
26
+ expect(isSameCoverageSubject(key('shared', 'team-a', 0, ''), 'shared', 'team-b', '')).toBe(false)
27
+ expect(isSameCoverageSubject(key('shared', 'team-a', 0, ''), 'shared', '', '')).toBe(false)
28
+ })
29
+
30
+ it('refuses it when the namespace view changed', () => {
31
+ expect(isSameCoverageSubject(key('p', '', 0, '?namespaces=app'), 'p', '', '?namespaces=other')).toBe(false)
32
+ })
33
+
34
+ it('refuses it when there is no previous query', () => {
35
+ expect(isSameCoverageSubject(undefined, 'p', '', '')).toBe(false)
36
+ expect(isSameCoverageSubject(['policy', 'coverage'], 'p', '', '')).toBe(false)
37
+ })
38
+ })
package/src/api/policy.ts CHANGED
@@ -1,5 +1,12 @@
1
- import { useQuery } from '@tanstack/react-query'
2
- import type { PolicyResourceResponse } from '@skyhook-io/k8s-ui'
1
+ import { useMutation, useQuery } from '@tanstack/react-query'
2
+ import type {
3
+ PolicyResourceResponse,
4
+ PolicyCoverageResponse,
5
+ PolicyQueuedResponse,
6
+ CNPGCatalogUsersResponse,
7
+ VeleroStoredBackupsResponse,
8
+ VeleroRunMessagesResponse,
9
+ } from '@skyhook-io/k8s-ui'
3
10
  import { fetchJSON } from './client'
4
11
 
5
12
  // /api/policy/resource/{kind}/{namespace}/{name}
@@ -21,3 +28,160 @@ export function usePolicyResource(kind: string, namespace: string, name: string,
21
28
  retry: false,
22
29
  })
23
30
  }
31
+
32
+ // /api/policy/policies/{policy}
33
+ //
34
+ // The inverse lookup: every resource one policy recorded an outcome for. A
35
+ // namespaced Kyverno Policy reports as "namespace/name", so the namespace is
36
+ // passed through and the server tries both shapes.
37
+ /**
38
+ * Whether a cached coverage result describes the same thing the caller is asking
39
+ * about now — same policy, namespace and view — so only a changed `limit`
40
+ * separates them. Exported for tests: the alternative is showing one policy's
41
+ * findings on another policy's page.
42
+ */
43
+ export function isSameCoverageSubject(
44
+ prevKey: readonly unknown[] | undefined,
45
+ policy: string,
46
+ namespace: string,
47
+ viewFilter: string,
48
+ ): boolean {
49
+ if (!prevKey || prevKey.length < 6) return false
50
+ return prevKey[2] === policy && prevKey[3] === namespace && prevKey[5] === viewFilter
51
+ }
52
+
53
+ // `limit` raises the per-rule subject bound. It is part of the query key so
54
+ // asking for more is a separate fetch rather than a mutation of the cached one,
55
+ // and the default response stays cached for every other drawer open.
56
+ export function usePolicyCoverage(
57
+ policy: string,
58
+ namespace?: string,
59
+ enabled = true,
60
+ limit?: number,
61
+ /**
62
+ * Whatever identifies the caller's current namespace view. The server applies
63
+ * that filter from session state, so it changes the response body without
64
+ * changing the URL — it has to be in the key or the cache serves the previous
65
+ * scope's answer under the new scope's heading.
66
+ */
67
+ viewFilter = '',
68
+ ) {
69
+ return useQuery<PolicyCoverageResponse>({
70
+ queryKey: ['policy', 'coverage', policy, namespace ?? '', limit ?? 0, viewFilter],
71
+ queryFn: () => {
72
+ const params = new URLSearchParams()
73
+ if (namespace) params.set('namespace', namespace)
74
+ if (limit) params.set('limit', String(limit))
75
+ const qs = params.toString()
76
+ return fetchJSON<PolicyCoverageResponse>(
77
+ `/policy/policies/${encodeURIComponent(policy)}${qs ? `?${qs}` : ''}`,
78
+ )
79
+ },
80
+ enabled: enabled && !!policy,
81
+ staleTime: 15000,
82
+ retry: false,
83
+ // Raising the limit is a new query key, and without this the section would
84
+ // fall back to its full loading state — asking to see MORE resources would
85
+ // briefly remove the ones already on screen.
86
+ //
87
+ // Only across a limit change, though. The drawer reuses this observer when
88
+ // it opens a different policy, so reusing unconditionally renders one
89
+ // policy's resources and counts under another's name, with no loading state
90
+ // to suggest they are not its own.
91
+ placeholderData: (prev, prevQuery) =>
92
+ isSameCoverageSubject(prevQuery?.queryKey, policy, namespace ?? '', viewFilter)
93
+ ? prev
94
+ : undefined,
95
+ })
96
+ }
97
+
98
+ // /api/policy/policies/{policy}/queued
99
+ //
100
+ // Kyverno records UpdateRequests in its own namespace whatever namespace the
101
+ // policy lives in, so this cannot be a client-side list: omitting the namespace
102
+ // on the generic resources endpoint means "apply the caller's namespace view
103
+ // filter", and a reader narrowed to their own namespace would be answered for
104
+ // the wrong scope. The server reads cluster-wide, gated on the caller's ability
105
+ // to list the kind — the same shape as the RBAC reverse lookups.
106
+ //
107
+ // No view filter in the key: the answer does not depend on one.
108
+ export function usePolicyQueued(policy: string, namespace = '', enabled = true) {
109
+ return useQuery<PolicyQueuedResponse>({
110
+ queryKey: ['policy', 'queued', policy, namespace],
111
+ queryFn: () =>
112
+ fetchJSON<PolicyQueuedResponse>(
113
+ `/policy/policies/${encodeURIComponent(policy)}/queued${
114
+ namespace ? `?namespace=${encodeURIComponent(namespace)}` : ''
115
+ }`,
116
+ ),
117
+ enabled: enabled && !!policy,
118
+ // These live for seconds, so a long stale window would describe a queue that
119
+ // has already drained.
120
+ staleTime: 5000,
121
+ retry: false,
122
+ })
123
+ }
124
+
125
+ // /api/cnpg/{imagecatalogs/{namespace}|clusterimagecatalogs}/{name}/clusters
126
+ //
127
+ // A ClusterImageCatalog is cluster-scoped and referenceable from any namespace,
128
+ // so the answer's scope is not the subject's. Asking the generic resources
129
+ // endpoint without a namespace inherits the caller's namespace view filter and
130
+ // would report "nothing uses this" on the strength of whichever namespaces they
131
+ // happen to be showing — an all-clear before an edit, derived from a browsing
132
+ // preference. Read server-side, gated on listing Clusters.
133
+ export function useCNPGCatalogUsers(name: string, namespace = '', enabled = true) {
134
+ const path = namespace
135
+ ? `/cnpg/imagecatalogs/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/clusters`
136
+ : `/cnpg/clusterimagecatalogs/${encodeURIComponent(name)}/clusters`
137
+ return useQuery<CNPGCatalogUsersResponse>({
138
+ queryKey: ['cnpg', 'catalog-users', namespace, name],
139
+ queryFn: () => fetchJSON<CNPGCatalogUsersResponse>(path),
140
+ enabled: enabled && !!name,
141
+ staleTime: 15000,
142
+ retry: false,
143
+ })
144
+ }
145
+
146
+ // /api/velero/backupstoragelocations/{namespace}/{name}/backups
147
+ //
148
+ // The inverse of the location a Backup names. Server-side and RBAC-gated for the
149
+ // same reason as the other reverse lookups: "this location holds nothing" is a
150
+ // sentence someone reads before deciding whether they can still restore, and it
151
+ // must not come from a browsing filter or a lookup that failed.
152
+ export function useVeleroStoredBackups(namespace: string, name: string, enabled = true) {
153
+ return useQuery<VeleroStoredBackupsResponse>({
154
+ queryKey: ['velero', 'stored-backups', namespace, name],
155
+ queryFn: () =>
156
+ fetchJSON<VeleroStoredBackupsResponse>(
157
+ `/velero/backupstoragelocations/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/backups`,
158
+ ),
159
+ enabled: enabled && !!namespace && !!name,
160
+ staleTime: 15000,
161
+ retry: false,
162
+ })
163
+ }
164
+
165
+ // POST /api/velero/{kind}/{namespace}/{name}/messages
166
+ //
167
+ // A mutation, like useRevealPodEnvironment — the closest thing this project
168
+ // already has: data that is deliberately not on the object, fetched only when
169
+ // someone asks for it. Reading a run's warnings makes Velero create a
170
+ // DownloadRequest and pulls an object out of storage, which is not work to do on
171
+ // every drawer open. The counts are already there to answer "is anything wrong".
172
+ //
173
+ // Mutation state is not keyed, so it is only correct while the drawer remounts
174
+ // the renderer as the selected resource changes. It does — verified by
175
+ // collapsing the cache key and watching the data still not leak across a switch.
176
+ export function useVeleroRunMessages(kind: 'backups' | 'restores', namespace: string, name: string) {
177
+ return useMutation<VeleroRunMessagesResponse, Error, void>({
178
+ mutationFn: () =>
179
+ fetchJSON<VeleroRunMessagesResponse>(
180
+ `/velero/${kind}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/messages`,
181
+ { method: 'POST' },
182
+ ),
183
+ // No meta.errorMessage: the failures here are the interesting part — a
184
+ // stopped controller, a denial, unreachable storage — and each is rendered
185
+ // in place next to the counts rather than thrown away in a toast.
186
+ })
187
+ }