@skyhook-io/radar-app 1.9.0 → 1.9.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 +7 -7
- package/src/App.tsx +69 -16
- package/src/api/apiResources.test.ts +11 -0
- package/src/api/apiResources.ts +51 -12
- package/src/api/client.capacity.test.ts +92 -0
- package/src/api/client.ts +2905 -2081
- package/src/api/config.test.ts +47 -0
- package/src/api/config.ts +15 -0
- package/src/api/diagnose.ts +15 -15
- package/src/components/ConnectionErrorView.test.tsx +88 -0
- package/src/components/ConnectionErrorView.tsx +128 -22
- package/src/components/capacity/CapacityActivity.tsx +787 -0
- package/src/components/capacity/CapacityDemand.tsx +961 -0
- package/src/components/capacity/CapacityOverview.tsx +1529 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
- package/src/components/capacity/CapacityView.test.tsx +2287 -0
- package/src/components/capacity/CapacityView.tsx +85 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
- package/src/components/capacity/DemandNomination.test.tsx +151 -0
- package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
- package/src/components/capacity/coverageCertainty.test.ts +162 -0
- package/src/components/capacity/podDemandGate.test.ts +47 -0
- package/src/components/capacity/podDemandGate.ts +22 -0
- package/src/components/capacity/schedulingBar.test.ts +244 -0
- package/src/components/capacity/shared.tsx +1841 -0
- package/src/components/diagnose/AISettings.tsx +21 -7
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +127 -57
- package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/diagnose/parts.test.tsx +125 -0
- package/src/components/diagnose/parts.tsx +166 -75
- package/src/components/home/CapacityCard.test.tsx +150 -0
- package/src/components/home/CapacityCard.tsx +125 -0
- package/src/components/home/HomeView.tsx +15 -1
- package/src/components/issues/IssuesPane.test.ts +142 -0
- package/src/components/issues/IssuesPane.tsx +142 -38
- package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
- package/src/components/nav/PrimaryNavRail.tsx +191 -103
- package/src/components/resources/ResourcesView.tsx +9 -8
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/settings/SettingsDialog.tsx +31 -19
- package/src/components/timeline/TimelineView.tsx +17 -3
- package/src/components/ui/command-items.ts +222 -98
- package/src/components/workload/WorkloadView.tsx +16 -83
- package/src/context/ConnectionContext.test.ts +39 -0
- package/src/context/ConnectionContext.tsx +155 -51
- package/src/context/DiagnoseCustomization.tsx +1 -1
- package/src/utils/shell-safe.test.ts +55 -0
- package/src/utils/shell-safe.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"yaml": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
44
|
+
"@skyhook-io/k8s-ui": ">=1.10.0",
|
|
45
45
|
"@tanstack/react-query": ">=5",
|
|
46
46
|
"@xyflow/react": ">=12.0.0",
|
|
47
47
|
"clsx": ">=2",
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@eslint/js": "^10.0.1",
|
|
57
|
-
"@playwright/test": "^1.
|
|
57
|
+
"@playwright/test": "^1.62.0",
|
|
58
58
|
"@skyhook-io/k8s-ui": "*",
|
|
59
59
|
"@tailwindcss/typography": "^0.5.20",
|
|
60
|
-
"@tailwindcss/vite": "^4.3.
|
|
61
|
-
"@tanstack/react-query": "^5.101.
|
|
60
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
61
|
+
"@tanstack/react-query": "^5.101.4",
|
|
62
62
|
"@types/node": "^26.1.1",
|
|
63
63
|
"@types/react": "^19.2.17",
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
|
-
"@vitejs/plugin-react": "^6.0.
|
|
65
|
+
"@vitejs/plugin-react": "^6.0.4",
|
|
66
66
|
"@xyflow/react": "^12.11.2",
|
|
67
67
|
"clsx": "^2.1.1",
|
|
68
68
|
"elkjs": "^0.11.1",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"react-dom": "^19.2.7",
|
|
78
78
|
"react-router-dom": "^7.18.1",
|
|
79
79
|
"tailwind-merge": "^3.6.0",
|
|
80
|
-
"tailwindcss": "^4.3.
|
|
80
|
+
"tailwindcss": "^4.3.3",
|
|
81
81
|
"typescript": "^6.0.2",
|
|
82
82
|
"typescript-eslint": "^8.62.0",
|
|
83
83
|
"vite": "^8.1.5"
|
package/src/App.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import { HelmView } from './components/helm/HelmView'
|
|
|
22
22
|
import { HelmCompareRoute } from './components/helm/HelmCompareRoute'
|
|
23
23
|
import { TrafficView } from './components/traffic/TrafficView'
|
|
24
24
|
import { CostView } from './components/cost/CostView'
|
|
25
|
+
import { CapacityView } from './components/capacity/CapacityView'
|
|
25
26
|
import { AuditView } from './components/audit/AuditView'
|
|
26
27
|
import { IssuesPane } from './components/issues/IssuesPane'
|
|
27
28
|
import { GitOpsView } from './components/gitops/GitOpsView'
|
|
@@ -50,17 +51,17 @@ import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
|
50
51
|
import { useEventSource } from './hooks/useEventSource'
|
|
51
52
|
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
|
|
52
53
|
import { buildAuditSeverityMap } from './utils/auditBadges'
|
|
53
|
-
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
|
|
54
|
+
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode, stripBasename } from './api/config'
|
|
54
55
|
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
|
|
55
56
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
56
57
|
import { useDocumentTitle } from './hooks/useDocumentTitle'
|
|
57
58
|
import type { ClusterLoadState } from './types/clusterLoadState'
|
|
58
59
|
import { useClusterLoadState } from './hooks/useClusterLoadState'
|
|
59
|
-
import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle, Loader2, RefreshCw } from 'lucide-react'
|
|
60
|
+
import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, Gauge, HelpCircle, Loader2, RefreshCw } from 'lucide-react'
|
|
60
61
|
import { useTheme } from './context/ThemeContext'
|
|
61
62
|
import { Tooltip } from './components/ui/Tooltip'
|
|
62
63
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
63
|
-
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
64
|
+
import { SettingsDialog, type SettingsSectionId } from './components/settings/SettingsDialog'
|
|
64
65
|
import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
65
66
|
import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
|
|
66
67
|
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
@@ -114,7 +115,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
114
115
|
|
|
115
116
|
// Convert API resource name back to topology node ID prefix
|
|
116
117
|
// Extended MainView type that includes traffic and cost
|
|
117
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
|
|
118
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
|
|
118
119
|
|
|
119
120
|
// Extract view from URL path
|
|
120
121
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
@@ -127,6 +128,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
127
128
|
if (path === 'helm') return 'helm'
|
|
128
129
|
if (path === 'traffic') return 'traffic'
|
|
129
130
|
if (path === 'cost') return 'cost'
|
|
131
|
+
if (path === 'capacity') return 'capacity'
|
|
130
132
|
if (path === 'workload') return 'workload'
|
|
131
133
|
if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
|
|
132
134
|
if (path === 'gitops') return 'gitops'
|
|
@@ -162,6 +164,12 @@ function namespaceFilterDisabled(
|
|
|
162
164
|
tooltip: 'Cost is reported per namespace across the whole cluster — the namespace filter doesn’t apply here.',
|
|
163
165
|
}
|
|
164
166
|
}
|
|
167
|
+
if (view === 'capacity') {
|
|
168
|
+
return {
|
|
169
|
+
disabled: true,
|
|
170
|
+
tooltip: 'Capacity is reported across the cluster — the namespace filter doesn’t apply here.',
|
|
171
|
+
}
|
|
172
|
+
}
|
|
165
173
|
const segments = pathname.replace(/^\//, '').split('/')
|
|
166
174
|
if (view === 'gitops' && segments[1] === 'detail') {
|
|
167
175
|
return {
|
|
@@ -226,6 +234,12 @@ function radarPageTitle(pathname: string, search = '', apiResources?: APIResourc
|
|
|
226
234
|
}
|
|
227
235
|
|
|
228
236
|
// The landing view reads "Overview" rather than "Home" in the tab.
|
|
237
|
+
if (view === 'capacity') {
|
|
238
|
+
if (pathSegments[1] === 'pools') return decode(pathSegments[2] ?? '') || 'Capacity'
|
|
239
|
+
if (pathSegments[1] === 'demand') return 'Capacity Demand'
|
|
240
|
+
if (pathSegments[1] === 'activity') return 'Capacity Activity'
|
|
241
|
+
}
|
|
242
|
+
|
|
229
243
|
if (view === 'home') return 'Overview'
|
|
230
244
|
// Every other view's label is its id capitalized — getViewFromPath has already
|
|
231
245
|
// normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
|
|
@@ -343,12 +357,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
343
357
|
// Auth check — detect if auth is enabled but user is not authenticated
|
|
344
358
|
const { data: authMe, isPending: authMePending } = useAuthMe()
|
|
345
359
|
|
|
346
|
-
// Restore navigation path after session-expiry re-auth redirect
|
|
360
|
+
// Restore navigation path after session-expiry re-auth redirect.
|
|
361
|
+
// The stored value is basename-relative, but strip defensively anyway:
|
|
362
|
+
// sessionStorage outlives app upgrades, so a value written by an older
|
|
363
|
+
// version may still carry the basename — and navigate() re-applies the
|
|
364
|
+
// basename, which would double it (/c/abc/c/abc/...). Trade-off: if a host
|
|
365
|
+
// mounts the app at a basename that exactly equals an internal route (e.g.
|
|
366
|
+
// /topology), this second strip eats the route and re-auth lands on home —
|
|
367
|
+
// accepted, since a wrong-but-valid landing beats a doubled URL.
|
|
347
368
|
useEffect(() => {
|
|
348
369
|
const returnPath = sessionStorage.getItem('radar_return_path')
|
|
349
370
|
if (returnPath) {
|
|
350
371
|
sessionStorage.removeItem('radar_return_path')
|
|
351
|
-
navigate(returnPath, { replace: true })
|
|
372
|
+
navigate(stripBasename(returnPath), { replace: true })
|
|
352
373
|
}
|
|
353
374
|
}, [navigate])
|
|
354
375
|
|
|
@@ -507,13 +528,23 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
507
528
|
|
|
508
529
|
// Settings dialog state
|
|
509
530
|
const [showSettings, setShowSettings] = useState(false)
|
|
531
|
+
const [settingsSection, setSettingsSection] = useState<SettingsSectionId>('overview')
|
|
532
|
+
const openSettings = useCallback((section: SettingsSectionId = 'overview') => {
|
|
533
|
+
setSettingsSection(section)
|
|
534
|
+
setShowSettings(true)
|
|
535
|
+
}, [])
|
|
510
536
|
|
|
511
537
|
// Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
|
|
512
538
|
useEffect(() => {
|
|
513
|
-
const handler = () =>
|
|
539
|
+
const handler = (event: Event) => {
|
|
540
|
+
const section =
|
|
541
|
+
(event as CustomEvent<{ section?: SettingsSectionId }>).detail?.section ??
|
|
542
|
+
'overview'
|
|
543
|
+
openSettings(section)
|
|
544
|
+
}
|
|
514
545
|
window.addEventListener('radar:open-settings', handler)
|
|
515
546
|
return () => window.removeEventListener('radar:open-settings', handler)
|
|
516
|
-
}, [])
|
|
547
|
+
}, [openSettings])
|
|
517
548
|
|
|
518
549
|
// Listen for "open-local-terminal" DOM event — the AI surface is portaled above
|
|
519
550
|
// the DockProvider, so it can't call useOpenLocalTerminal directly; it dispatches
|
|
@@ -768,7 +799,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
768
799
|
const VIEW_SHORTCUT_KEYS: Record<ExtendedMainView, string> = {
|
|
769
800
|
home: 'g h', resources: 'g r', issues: 'g i', topology: 'g t',
|
|
770
801
|
applications: 'g a', timeline: 'g l', traffic: 'g f', helm: 'g m',
|
|
771
|
-
gitops: 'g o', checks: 'g u', cost: 'g c',
|
|
802
|
+
gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p',
|
|
772
803
|
// Non-rail views (reachable via deep links / actions, not the rail) get no
|
|
773
804
|
// dedicated mnemonic — listed for exhaustiveness so the type stays total.
|
|
774
805
|
workload: '', compare: '', helmCompare: '',
|
|
@@ -853,7 +884,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
853
884
|
description: 'Open settings',
|
|
854
885
|
category: 'General' as const,
|
|
855
886
|
scope: 'global' as const,
|
|
856
|
-
handler: () =>
|
|
887
|
+
handler: () => openSettings(),
|
|
857
888
|
}]
|
|
858
889
|
: []),
|
|
859
890
|
])
|
|
@@ -948,9 +979,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
948
979
|
const fastInvalidationRef = useRef<{
|
|
949
980
|
changedKinds: Set<string> // every changed kind (any op) → detail drawer
|
|
950
981
|
structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
|
|
982
|
+
environmentNamespaces: Set<string>
|
|
983
|
+
environmentPods: Map<string, Set<string>>
|
|
951
984
|
secretsChanged: boolean
|
|
952
985
|
timer: number | null
|
|
953
|
-
}>({ changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null })
|
|
986
|
+
}>({ changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null })
|
|
954
987
|
const slowInvalidationRef = useRef<{
|
|
955
988
|
updatedKinds: Set<string> // update-only churn → throttled list + dashboard
|
|
956
989
|
timer: number | null
|
|
@@ -985,6 +1018,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
985
1018
|
fast.changedKinds.add(kind)
|
|
986
1019
|
if (structural) fast.structuralKinds.add(kind)
|
|
987
1020
|
if (kind === 'secrets') fast.secretsChanged = true
|
|
1021
|
+
if ((kind === 'configmaps' || kind === 'secrets') && event.namespace) fast.environmentNamespaces.add(event.namespace)
|
|
1022
|
+
if (kind === 'pods' && event.namespace && event.name) {
|
|
1023
|
+
const names = fast.environmentPods.get(event.namespace) ?? new Set<string>()
|
|
1024
|
+
names.add(event.name)
|
|
1025
|
+
fast.environmentPods.set(event.namespace, names)
|
|
1026
|
+
}
|
|
988
1027
|
|
|
989
1028
|
const slow = slowInvalidationRef.current
|
|
990
1029
|
if (!structural) slow.updatedKinds.add(kind)
|
|
@@ -1006,11 +1045,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1006
1045
|
if (f.secretsChanged) {
|
|
1007
1046
|
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
1008
1047
|
}
|
|
1048
|
+
for (const namespace of f.environmentNamespaces) {
|
|
1049
|
+
queryClient.invalidateQueries({ queryKey: ['pod-environment', namespace] })
|
|
1050
|
+
}
|
|
1051
|
+
for (const [namespace, names] of f.environmentPods) {
|
|
1052
|
+
for (const name of names) {
|
|
1053
|
+
queryClient.invalidateQueries({ queryKey: ['pod-environment', namespace, name] })
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1009
1056
|
// GitOps behavior unchanged from before — refreshes every batch when a
|
|
1010
1057
|
// GitOps view is mounted (Phase 2 will make this relevance-aware).
|
|
1011
1058
|
queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
|
|
1012
1059
|
queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
|
|
1013
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1060
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1014
1061
|
}, 3000)
|
|
1015
1062
|
}
|
|
1016
1063
|
|
|
@@ -1036,7 +1083,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1036
1083
|
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
1037
1084
|
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
1038
1085
|
if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
|
|
1039
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1086
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1040
1087
|
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1041
1088
|
timelineInvalidationRef.current = { timer: null }
|
|
1042
1089
|
}, [])
|
|
@@ -1059,7 +1106,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1059
1106
|
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
1060
1107
|
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
1061
1108
|
if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
|
|
1062
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1109
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1063
1110
|
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1064
1111
|
timelineInvalidationRef.current = { timer: null }
|
|
1065
1112
|
|
|
@@ -1599,7 +1646,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1599
1646
|
pinned={navRailEffectivePinned}
|
|
1600
1647
|
onTogglePinned={toggleNavRailPinned}
|
|
1601
1648
|
showPinToggle={!railForcedSlim}
|
|
1602
|
-
onOpenSettings={() =>
|
|
1649
|
+
onOpenSettings={() => openSettings()}
|
|
1603
1650
|
accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
|
|
1604
1651
|
/>
|
|
1605
1652
|
)}
|
|
@@ -1712,6 +1759,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1712
1759
|
{ view: 'home' as const, icon: Home, label: 'Home' },
|
|
1713
1760
|
{ view: 'topology' as const, icon: Network, label: 'Topology' },
|
|
1714
1761
|
{ view: 'resources' as const, icon: List, label: 'Resources' },
|
|
1762
|
+
{ view: 'capacity' as const, icon: Gauge, label: 'Capacity' },
|
|
1715
1763
|
{ view: 'timeline' as const, icon: Clock, label: 'Timeline' },
|
|
1716
1764
|
{ view: 'helm' as const, icon: Package, label: 'Helm' },
|
|
1717
1765
|
{ view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
|
|
@@ -2186,7 +2234,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2186
2234
|
navigateToResource(resource)
|
|
2187
2235
|
}}
|
|
2188
2236
|
onClearNamespaces={clearAllNamespaces}
|
|
2189
|
-
onOpenSettings={() =>
|
|
2237
|
+
onOpenSettings={() => openSettings()}
|
|
2190
2238
|
/>
|
|
2191
2239
|
)}
|
|
2192
2240
|
|
|
@@ -2221,6 +2269,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2221
2269
|
<CostView namespaces={namespaces} onBack={() => setMainView('home')} onOpenResource={navigateToResource} />
|
|
2222
2270
|
)}
|
|
2223
2271
|
|
|
2272
|
+
{mainView === 'capacity' && (
|
|
2273
|
+
<CapacityView onOpenResource={navigateToResource} />
|
|
2274
|
+
)}
|
|
2275
|
+
|
|
2224
2276
|
{/* Takeover splash. When the host claims the current view via
|
|
2225
2277
|
fleetTakeoverHref, the redirect effect above is mid-flight — render a
|
|
2226
2278
|
brief splash instead of the inline view (which would flash + fire its
|
|
@@ -2417,6 +2469,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2417
2469
|
{/* Settings dialog — My permissions is rendered inline in its own section */}
|
|
2418
2470
|
<SettingsDialog
|
|
2419
2471
|
open={showSettings}
|
|
2472
|
+
initialSection={settingsSection}
|
|
2420
2473
|
onClose={() => setShowSettings(false)}
|
|
2421
2474
|
/>
|
|
2422
2475
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { hasKarpenterNodePools } from './apiResources'
|
|
3
|
+
|
|
4
|
+
describe('hasKarpenterNodePools', () => {
|
|
5
|
+
it('uses exact API discovery rather than top-CRD counts or kind name alone', () => {
|
|
6
|
+
expect(hasKarpenterNodePools([{ name: 'nodepools', kind: 'NodePool', group: 'karpenter.sh', verbs: ['get', 'list'] } as any])).toBe(true)
|
|
7
|
+
expect(hasKarpenterNodePools([{ name: 'nodepools', kind: 'NodePool', group: 'example.io', verbs: ['list'] } as any])).toBe(false)
|
|
8
|
+
expect(hasKarpenterNodePools([{ name: 'nodepools', kind: 'NodePool', group: 'karpenter.sh', verbs: ['get'] } as any])).toBe(false)
|
|
9
|
+
expect(hasKarpenterNodePools(undefined)).toBe(false)
|
|
10
|
+
})
|
|
11
|
+
})
|
package/src/api/apiResources.ts
CHANGED
|
@@ -1,28 +1,67 @@
|
|
|
1
|
-
import { useQuery } from
|
|
2
|
-
import type { APIResource } from
|
|
3
|
-
import { apiUrl, getAuthHeaders, getCredentialsMode } from
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { APIResource } from "../types";
|
|
3
|
+
import { apiUrl, getAuthHeaders, getCredentialsMode } from "./config";
|
|
4
4
|
|
|
5
5
|
// Re-export pure functions from package
|
|
6
|
-
export {
|
|
7
|
-
|
|
6
|
+
export {
|
|
7
|
+
categorizeResources,
|
|
8
|
+
CORE_RESOURCES,
|
|
9
|
+
findAPIResourceForRoute,
|
|
10
|
+
formatGroupName,
|
|
11
|
+
shortenGroupName,
|
|
12
|
+
getKindLabel,
|
|
13
|
+
getKindPlural,
|
|
14
|
+
} from "@skyhook-io/k8s-ui";
|
|
15
|
+
export type { ResourceCategory } from "@skyhook-io/k8s-ui";
|
|
8
16
|
|
|
9
17
|
async function fetchJSON<T>(path: string): Promise<T> {
|
|
10
18
|
const response = await fetch(apiUrl(path), {
|
|
11
19
|
credentials: getCredentialsMode(),
|
|
12
20
|
headers: getAuthHeaders(),
|
|
13
|
-
})
|
|
21
|
+
});
|
|
14
22
|
if (!response.ok) {
|
|
15
|
-
const error = await response
|
|
16
|
-
|
|
23
|
+
const error = await response
|
|
24
|
+
.json()
|
|
25
|
+
.catch(() => ({ error: "Unknown error" }));
|
|
26
|
+
throw new Error(error.error || `HTTP ${response.status}`);
|
|
17
27
|
}
|
|
18
|
-
return response.json()
|
|
28
|
+
return response.json();
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
// Fetch all API resources from the cluster
|
|
22
32
|
export function useAPIResources() {
|
|
23
33
|
return useQuery<APIResource[]>({
|
|
24
|
-
queryKey: [
|
|
25
|
-
queryFn: () => fetchJSON(
|
|
34
|
+
queryKey: ["api-resources"],
|
|
35
|
+
queryFn: () => fetchJSON("/api-resources"),
|
|
26
36
|
staleTime: 5 * 60 * 1000, // 5 minutes - resources don't change often
|
|
27
|
-
})
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hasKarpenterNodePools(
|
|
41
|
+
resources: APIResource[] | undefined,
|
|
42
|
+
): boolean {
|
|
43
|
+
return (
|
|
44
|
+
resources?.some(
|
|
45
|
+
(resource) =>
|
|
46
|
+
resource.name === "nodepools" &&
|
|
47
|
+
resource.group === "karpenter.sh" &&
|
|
48
|
+
resource.verbs?.includes("list"),
|
|
49
|
+
) ?? false
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Whether the Capacity screens are reachable for this caller — the gate on
|
|
54
|
+
// links and entry points into them. The per-request capability is authoritative
|
|
55
|
+
// once it has resolved discovery + RBAC: available opens, denied/not_detected
|
|
56
|
+
// closes (an RBAC-denied user would otherwise land on a 403 page). While the
|
|
57
|
+
// capability is still syncing pre-discovery (cluster connecting), fall back to
|
|
58
|
+
// the discovery signal so entry points don't flicker.
|
|
59
|
+
export function karpenterCapacityAvailable(
|
|
60
|
+
capability: { state: string } | undefined,
|
|
61
|
+
apiResources: APIResource[] | undefined,
|
|
62
|
+
): boolean {
|
|
63
|
+
if (capability?.state === "available") return true;
|
|
64
|
+
if (capability?.state === "denied" || capability?.state === "not_detected")
|
|
65
|
+
return false;
|
|
66
|
+
return hasKarpenterNodePools(apiResources);
|
|
28
67
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ApiError,
|
|
4
|
+
capacityRefetchInterval,
|
|
5
|
+
isCapacityCursorInvalidError,
|
|
6
|
+
shouldRetryCapacityQuery,
|
|
7
|
+
} from "./client";
|
|
8
|
+
|
|
9
|
+
describe("isCapacityCursorInvalidError", () => {
|
|
10
|
+
it("uses the structured capacity error code", () => {
|
|
11
|
+
expect(
|
|
12
|
+
isCapacityCursorInvalidError(
|
|
13
|
+
new ApiError("cursor expired", 400, {
|
|
14
|
+
error_code: "capacity_cursor_invalid",
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not infer cursor failures from status or message text", () => {
|
|
21
|
+
expect(
|
|
22
|
+
isCapacityCursorInvalidError(new ApiError("cursor expired", 400)),
|
|
23
|
+
).toBe(false);
|
|
24
|
+
expect(
|
|
25
|
+
isCapacityCursorInvalidError(
|
|
26
|
+
new ApiError("unrelated", 400, { code: "another_error" }),
|
|
27
|
+
),
|
|
28
|
+
).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("shouldRetryCapacityQuery", () => {
|
|
33
|
+
it("does not retry stable client or authorization failures", () => {
|
|
34
|
+
expect(
|
|
35
|
+
shouldRetryCapacityQuery(0, new ApiError("invalid since", 400)),
|
|
36
|
+
).toBe(false);
|
|
37
|
+
expect(shouldRetryCapacityQuery(0, new ApiError("forbidden", 403))).toBe(
|
|
38
|
+
false,
|
|
39
|
+
);
|
|
40
|
+
expect(shouldRetryCapacityQuery(0, new ApiError("not found", 404))).toBe(
|
|
41
|
+
false,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("bounds retries for transient failures", () => {
|
|
46
|
+
expect(shouldRetryCapacityQuery(0, new ApiError("unavailable", 503))).toBe(
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
expect(shouldRetryCapacityQuery(3, new ApiError("unavailable", 503))).toBe(
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("capacityRefetchInterval", () => {
|
|
56
|
+
const withData = (data?: { state?: string }) => ({ state: { data } });
|
|
57
|
+
|
|
58
|
+
it("suspends polling while the user holds a page cursor", () => {
|
|
59
|
+
// A background refetch on page 2 races membership changes and yanks the
|
|
60
|
+
// user back to page 1.
|
|
61
|
+
expect(
|
|
62
|
+
capacityRefetchInterval(undefined, true, "cursor-abc")(withData()),
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("polls fast while the server reports syncing", () => {
|
|
67
|
+
expect(
|
|
68
|
+
capacityRefetchInterval(undefined, true)(withData({ state: "syncing" })),
|
|
69
|
+
).toBe(2_500);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("arms the normal cadence for a settled, enabled query", () => {
|
|
73
|
+
expect(
|
|
74
|
+
capacityRefetchInterval(
|
|
75
|
+
undefined,
|
|
76
|
+
true,
|
|
77
|
+
)(withData({ state: "available" })),
|
|
78
|
+
).toBe(30_000);
|
|
79
|
+
expect(
|
|
80
|
+
capacityRefetchInterval(
|
|
81
|
+
{ refetchInterval: 5_000 },
|
|
82
|
+
true,
|
|
83
|
+
)(withData({ state: "available" })),
|
|
84
|
+
).toBe(5_000);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not poll a disabled query, even while syncing", () => {
|
|
88
|
+
expect(
|
|
89
|
+
capacityRefetchInterval(undefined, false)(withData({ state: "syncing" })),
|
|
90
|
+
).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|