@skyhook-io/radar-app 1.9.7 → 1.11.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 (41) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +2 -6
  3. package/src/api/client.ts +138 -6
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +187 -0
  6. package/src/components/home/HomeView.tsx +15 -3
  7. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  8. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  9. package/src/components/home/TopologyPreview.tsx +57 -11
  10. package/src/components/home/mcpToolCatalog.ts +15 -5
  11. package/src/components/resources/CompositeRenderer.tsx +60 -3
  12. package/src/components/resources/ResourcesView.tsx +15 -3
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  15. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  17. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  18. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  19. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  20. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  21. package/src/components/resources/renderers/PodRenderer.tsx +8 -0
  22. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  23. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  24. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  25. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +9 -0
  27. package/src/components/resources/renderers/index.ts +1 -0
  28. package/src/components/traffic/TrafficFilterSidebar.tsx +38 -21
  29. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  30. package/src/components/traffic/TrafficGraph.tsx +155 -63
  31. package/src/components/traffic/TrafficView.tsx +168 -52
  32. package/src/components/traffic/TrafficWizard.tsx +13 -1
  33. package/src/components/traffic/trafficFilters.test.ts +103 -0
  34. package/src/components/traffic/trafficFilters.ts +117 -0
  35. package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
  36. package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
  37. package/src/components/workload/WorkloadView.tsx +55 -7
  38. package/src/utils/navigation.ts +44 -2
  39. package/src/utils/network-policy-navigation.test.ts +68 -0
  40. package/src/utils/topology-selection.test.ts +40 -0
  41. package/src/utils/topology-selection.ts +39 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.9.7",
3
+ "version": "1.11.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",
@@ -37,11 +37,11 @@
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.11.0",
44
+ "@skyhook-io/k8s-ui": ">=1.13.0",
45
45
  "@tanstack/react-query": ">=5",
46
46
  "@xyflow/react": ">=12.0.0",
47
47
  "clsx": ">=2",
@@ -59,7 +59,7 @@
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
65
  "@vitejs/plugin-react": "^6.0.4",
@@ -70,7 +70,7 @@
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",
73
+ "lucide-react": "^1.30.0",
74
74
  "postcss": "^8.5.25",
75
75
  "prettier": "^3.9.5",
76
76
  "react": "^19.2.8",
@@ -80,7 +80,7 @@
80
80
  "tailwindcss": "^4.3.3",
81
81
  "typescript": "^6.0.2",
82
82
  "typescript-eslint": "^8.62.0",
83
- "vite": "^8.1.5"
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
 
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;
@@ -508,7 +503,9 @@ export interface DashboardCertificateHealth {
508
503
 
509
504
  export interface DashboardNetworkPolicyCoverage {
510
505
  totalPolicies: number;
506
+ stagedPolicies?: number;
511
507
  coveredWorkloads: number;
508
+ coveredWorkloadsIfStaged?: number;
512
509
  totalWorkloads: number;
513
510
  }
514
511
 
@@ -543,7 +540,6 @@ export interface DashboardResponse {
543
540
  problems: DashboardProblem[]
544
541
  resourceCounts: DashboardResourceCounts
545
542
  recentChanges: DashboardChange[]
546
- topologySummary: DashboardTopologySummary
547
543
  trafficSummary: DashboardTrafficSummary | null
548
544
  metrics: DashboardMetrics | null
549
545
  metricsServerAvailable: boolean
@@ -4388,6 +4384,10 @@ export interface WorkloadRevision {
4388
4384
  isCurrent: boolean;
4389
4385
  replicas: number;
4390
4386
  template?: string; // Pod template spec as YAML (for revision diff)
4387
+ // Argo Rollouts only: mid-canary isCurrent (rolling out) and isStable
4388
+ // (serving traffic, what an abort reverts to) are different revisions.
4389
+ isStable?: boolean;
4390
+ podHash?: string;
4391
4391
  }
4392
4392
 
4393
4393
  export function useWorkloadRevisions(
@@ -4463,6 +4463,119 @@ export function useRollbackWorkload() {
4463
4463
  });
4464
4464
  }
4465
4465
 
4466
+ // ============================================================================
4467
+ // Argo Rollouts progressive-delivery control plane
4468
+ // ============================================================================
4469
+
4470
+ export type RolloutAction =
4471
+ | "abort"
4472
+ | "retry"
4473
+ | "promote"
4474
+ | "promote-full"
4475
+ | "skip-step";
4476
+
4477
+ export interface RolloutCapabilities {
4478
+ abort: boolean;
4479
+ retry: boolean;
4480
+ promote: boolean;
4481
+ promoteFull: boolean;
4482
+ skipStep: boolean;
4483
+ rollback: boolean;
4484
+ restart: boolean;
4485
+ strategy: string;
4486
+ terminating: boolean;
4487
+ }
4488
+
4489
+ export function useRolloutCapabilities(
4490
+ namespace: string,
4491
+ name: string,
4492
+ enabled = true,
4493
+ ) {
4494
+ return useQuery<RolloutCapabilities>({
4495
+ queryKey: ["rollout-capabilities", namespace, name],
4496
+ queryFn: () => fetchJSON(`/rollouts/${namespace}/${name}/capabilities`),
4497
+ enabled: Boolean(namespace && name && enabled),
4498
+ });
4499
+ }
4500
+
4501
+ const ROLLOUT_ACTION_MESSAGES: Record<
4502
+ RolloutAction,
4503
+ { errorMessage: string; successMessage: string }
4504
+ > = {
4505
+ abort: {
4506
+ errorMessage: "Failed to abort rollout",
4507
+ successMessage: "Rollout aborted — traffic reverted to the stable version",
4508
+ },
4509
+ retry: {
4510
+ errorMessage: "Failed to retry rollout",
4511
+ successMessage: "Rollout retried",
4512
+ },
4513
+ promote: {
4514
+ errorMessage: "Failed to promote rollout",
4515
+ successMessage: "Rollout promoted",
4516
+ },
4517
+ "promote-full": {
4518
+ errorMessage: "Failed to promote rollout",
4519
+ successMessage: "Rollout promoted to full — remaining steps skipped",
4520
+ },
4521
+ "skip-step": {
4522
+ errorMessage: "Failed to skip step",
4523
+ successMessage: "Skipped to the next step",
4524
+ },
4525
+ };
4526
+
4527
+ export function useRolloutAction() {
4528
+ const queryClient = useQueryClient();
4529
+ return useMutation({
4530
+ mutationFn: async ({
4531
+ action,
4532
+ namespace,
4533
+ name,
4534
+ }: {
4535
+ action: RolloutAction;
4536
+ namespace: string;
4537
+ name: string;
4538
+ }) => {
4539
+ const response = await apiFetch(
4540
+ `${getApiBase()}/rollouts/${namespace}/${name}/${action}`,
4541
+ { method: "POST" },
4542
+ );
4543
+ if (!response.ok) {
4544
+ const error = await response
4545
+ .json()
4546
+ .catch(() => ({ error: "Unknown error" }));
4547
+ throw new Error(error.error || `HTTP ${response.status}`);
4548
+ }
4549
+ return response.json();
4550
+ },
4551
+ // meta is static, so per-action success wording goes through onSuccess.
4552
+ meta: { errorMessage: "Rollout action failed" },
4553
+ onSuccess: (_, variables) => {
4554
+ showApiSuccess(ROLLOUT_ACTION_MESSAGES[variables.action].successMessage);
4555
+ queryClient.invalidateQueries({ queryKey: ["resources", "rollouts"] });
4556
+ queryClient.invalidateQueries({
4557
+ queryKey: ["resource", "rollouts", variables.namespace, variables.name],
4558
+ });
4559
+ queryClient.invalidateQueries({
4560
+ queryKey: [
4561
+ "rollout-capabilities",
4562
+ variables.namespace,
4563
+ variables.name,
4564
+ ],
4565
+ });
4566
+ queryClient.invalidateQueries({
4567
+ queryKey: [
4568
+ "workload-revisions",
4569
+ "rollouts",
4570
+ variables.namespace,
4571
+ variables.name,
4572
+ ],
4573
+ });
4574
+ queryClient.invalidateQueries({ queryKey: ["topology"] });
4575
+ },
4576
+ });
4577
+ }
4578
+
4466
4579
  // ============================================================================
4467
4580
  // Node operations (cordon, uncordon, drain)
4468
4581
  // ============================================================================
@@ -6213,6 +6326,14 @@ export interface DiagErrorEntry {
6213
6326
  level: string;
6214
6327
  }
6215
6328
 
6329
+ export interface DiagEnvVar {
6330
+ key: string;
6331
+ value: string;
6332
+ /** Whether the variable exists at all. An empty value with set=true is a
6333
+ * distinct state — it suppresses the desktop app's own WebKit defaults. */
6334
+ set: boolean;
6335
+ }
6336
+
6216
6337
  export type DiagSyncPhase =
6217
6338
  "not_started" | "syncing_critical" | "syncing_deferred" | "complete";
6218
6339
 
@@ -6362,6 +6483,17 @@ export interface DiagnosticsSnapshot {
6362
6483
  connectedClients: number;
6363
6484
  };
6364
6485
  perf?: DiagPerfSnapshot;
6486
+ /** Desktop app on Linux only — the host rendering environment. */
6487
+ desktop?: {
6488
+ sessionType?: string;
6489
+ desktopEnvironment?: string;
6490
+ displayServer?: string;
6491
+ /** Every override key, including ones the host never set. */
6492
+ renderOverrides?: DiagEnvVar[];
6493
+ sandbox?: DiagEnvVar[];
6494
+ webkitLibrary?: string;
6495
+ gpuPolicy?: string;
6496
+ };
6365
6497
  runtime?: {
6366
6498
  heapMB: number;
6367
6499
  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
+ })
@@ -0,0 +1,187 @@
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'
10
+ import { fetchJSON } from './client'
11
+
12
+ // /api/policy/resource/{kind}/{namespace}/{name}
13
+ //
14
+ // Policy results change when the engine rescans rather than on every render, so
15
+ // a short stale window keeps drawer navigation instant without going stale in a
16
+ // way an operator would notice.
17
+ export function usePolicyResource(kind: string, namespace: string, name: string, enabled = true) {
18
+ return useQuery<PolicyResourceResponse>({
19
+ queryKey: ['policy', 'resource', kind, namespace, name],
20
+ queryFn: () =>
21
+ fetchJSON<PolicyResourceResponse>(
22
+ `/policy/resource/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
23
+ ),
24
+ enabled: enabled && !!kind && !!namespace && !!name,
25
+ staleTime: 15000,
26
+ // A 403 is a settled answer about this identity, not a blip — retrying
27
+ // would just repeat the denial on every drawer open.
28
+ retry: false,
29
+ })
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
+ }
@@ -14,6 +14,7 @@ import { GitOpsControllersCard } from './GitOpsControllersCard'
14
14
  import { CapacityCard } from './CapacityCard'
15
15
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
16
16
  import { Tooltip } from '../ui/Tooltip'
17
+ import { getNetworkPolicyResourceTarget } from '../../utils/navigation'
17
18
  import {
18
19
  AuditCard,
19
20
  FreshnessControl,
@@ -73,8 +74,13 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
73
74
  })
74
75
  const nodeIds = new Set(nodes.map(n => n.id))
75
76
  const edges = topology.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
76
- return { nodes, edges }
77
+ // Carry the frame's own flags through the filter. A large cluster serves an
78
+ // empty graph flagged requiresNamespaceFilter until SSE picks the pick up
79
+ // server-side, and a consumer that sees only nodes/edges reads that
80
+ // declined build as a namespace holding nothing.
81
+ return { ...topology, nodes, edges }
77
82
  }, [topology, namespaces])
83
+ const networkPolicyTarget = getNetworkPolicyResourceTarget(scopedTopology)
78
84
  // CRDs and Helm load lazily after main dashboard to keep initial load fast
79
85
  const { data: crdsData } = useDashboardCRDs(namespaces)
80
86
  const { data: helmData } = useDashboardHelm(namespaces)
@@ -150,7 +156,7 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
150
156
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
151
157
  <TopologyPreview
152
158
  topology={scopedTopology}
153
- summary={data.topologySummary}
159
+ namespaceSelected={namespaces.length > 0}
154
160
  onNavigate={() => onNavigateToView('topology')}
155
161
  />
156
162
  <ActivitySummary
@@ -198,7 +204,13 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
198
204
  <BandItem>
199
205
  <NetworkPolicyCoverageCard
200
206
  data={data.networkPolicyCoverage}
201
- onNavigate={() => onNavigateToResourceKind('networkpolicies', 'networking.k8s.io')}
207
+ onNavigate={() => {
208
+ if (networkPolicyTarget) {
209
+ onNavigateToResourceKind(networkPolicyTarget.kind, networkPolicyTarget.group)
210
+ } else {
211
+ onNavigateToView('resources')
212
+ }
213
+ }}
202
214
  />
203
215
  </BandItem>
204
216
  )}
@@ -0,0 +1,81 @@
1
+ import type React from "react";
2
+ import { renderToString } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import { NetworkPolicyCoverageCard } from "./NetworkPolicyCoverageCard";
5
+
6
+ // React separates adjacent text nodes with comment markers. Strip them so an
7
+ // assertion matches the text a reader sees instead of a style-attribute value
8
+ // that happens to contain the same characters.
9
+ const render = (element: React.ReactElement) =>
10
+ renderToString(element).replaceAll("<!-- -->", "");
11
+
12
+ // The card's own accent turns red at low coverage, so an assertion has to look
13
+ // at the striped staged segment rather than at the whole card.
14
+ const stagedSegmentClass = (html: string) => {
15
+ const match = html.match(/class="([^"]*)"[^>]*repeating-linear-gradient/);
16
+ if (!match) throw new Error("no staged segment rendered");
17
+ return match[1];
18
+ };
19
+
20
+ describe("NetworkPolicyCoverageCard", () => {
21
+ it("shows enforced and staged preview coverage separately", () => {
22
+ const html = render(
23
+ <NetworkPolicyCoverageCard
24
+ data={{
25
+ totalPolicies: 6,
26
+ stagedPolicies: 2,
27
+ coveredWorkloads: 3,
28
+ coveredWorkloadsIfStaged: 5,
29
+ totalWorkloads: 10,
30
+ }}
31
+ onNavigate={() => {}}
32
+ />,
33
+ );
34
+
35
+ expect(html).toContain("30%");
36
+ expect(html).toContain("(50% if staged applied)");
37
+ expect(html).toContain("Covered workloads");
38
+ expect(html).toContain("Covered if staged");
39
+ expect(html).toContain("Uncovered workloads");
40
+ expect(html).toContain("Uncovered if staged");
41
+ // The delta segment's tone carries the direction: amber for coverage the
42
+ // staged set would add. Its wording lives in a Tooltip portal, which server
43
+ // rendering does not emit, so the class is what an assertion can reach.
44
+ expect(stagedSegmentClass(html)).toContain("text-yellow-500");
45
+ });
46
+
47
+ it("shows coverage going down when a staged policy stages a deletion", () => {
48
+ const html = render(
49
+ <NetworkPolicyCoverageCard
50
+ data={{
51
+ totalPolicies: 6,
52
+ stagedPolicies: 1,
53
+ coveredWorkloads: 8,
54
+ coveredWorkloadsIfStaged: 5,
55
+ totalWorkloads: 10,
56
+ }}
57
+ onNavigate={() => {}}
58
+ />,
59
+ );
60
+
61
+ expect(html).toContain("80%");
62
+ expect(html).toContain("(50% if staged applied)");
63
+ // Red, not amber: this staged set takes coverage away.
64
+ expect(stagedSegmentClass(html)).toContain("text-red-500");
65
+ // The projected figure must not be clamped up to today's coverage.
66
+ expect(html).toContain("5/10");
67
+ });
68
+
69
+ it("keeps the original enforced-only presentation without staged policies", () => {
70
+ const html = render(
71
+ <NetworkPolicyCoverageCard
72
+ data={{ totalPolicies: 2, coveredWorkloads: 2, totalWorkloads: 4 }}
73
+ onNavigate={() => {}}
74
+ />,
75
+ );
76
+
77
+ expect(html).toContain("50%");
78
+ expect(html).not.toContain("if staged");
79
+ expect(html).not.toContain("repeating-linear-gradient");
80
+ });
81
+ });