@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.
- package/package.json +10 -10
- package/src/App.tsx +5 -8
- package/src/api/client.ts +174 -12
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- package/src/components/ConnectionErrorView.test.tsx +53 -0
- package/src/components/ConnectionErrorView.tsx +15 -12
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/audit/UpgradeReadinessView.tsx +3 -3
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/home/ClusterHealthCard.tsx +3 -0
- package/src/components/home/CostCard.tsx +12 -7
- 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 +27 -5
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- 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/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/index.ts +1 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +160 -36
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- 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 +115 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- 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/src/utils/wails-clipboard.test.ts +109 -0
- 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.
|
|
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.
|
|
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
|
|
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.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.
|
|
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
|
|
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.
|
|
74
|
-
"postcss": "^8.5.
|
|
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.
|
|
83
|
-
"vite": "^8.1
|
|
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
|
|
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.
|
|
1672
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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 }) =>
|
|
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 {
|
|
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
|
+
}
|