@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.
- package/package.json +6 -6
- package/src/App.tsx +2 -6
- package/src/api/client.ts +138 -6
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +187 -0
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +15 -5
- package/src/components/resources/CompositeRenderer.tsx +60 -3
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/PodRenderer.tsx +8 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +9 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +38 -21
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +155 -63
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
- package/src/components/workload/WorkloadView.tsx +55 -7
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- 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.
|
|
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
|
|
40
|
+
"shiki": "^4.4.2",
|
|
41
41
|
"yaml": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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={() =>
|
|
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
|
+
});
|