@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.
Files changed (52) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +69 -16
  3. package/src/api/apiResources.test.ts +11 -0
  4. package/src/api/apiResources.ts +51 -12
  5. package/src/api/client.capacity.test.ts +92 -0
  6. package/src/api/client.ts +2905 -2081
  7. package/src/api/config.test.ts +47 -0
  8. package/src/api/config.ts +15 -0
  9. package/src/api/diagnose.ts +15 -15
  10. package/src/components/ConnectionErrorView.test.tsx +88 -0
  11. package/src/components/ConnectionErrorView.tsx +128 -22
  12. package/src/components/capacity/CapacityActivity.tsx +787 -0
  13. package/src/components/capacity/CapacityDemand.tsx +961 -0
  14. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  15. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  16. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  17. package/src/components/capacity/CapacityView.tsx +85 -0
  18. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  19. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  20. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  21. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  22. package/src/components/capacity/podDemandGate.test.ts +47 -0
  23. package/src/components/capacity/podDemandGate.ts +22 -0
  24. package/src/components/capacity/schedulingBar.test.ts +244 -0
  25. package/src/components/capacity/shared.tsx +1841 -0
  26. package/src/components/diagnose/AISettings.tsx +21 -7
  27. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  28. package/src/components/diagnose/DiagnoseContext.tsx +127 -57
  29. package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
  30. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  31. package/src/components/diagnose/agentCatalog.ts +30 -0
  32. package/src/components/diagnose/parts.test.tsx +125 -0
  33. package/src/components/diagnose/parts.tsx +166 -75
  34. package/src/components/home/CapacityCard.test.tsx +150 -0
  35. package/src/components/home/CapacityCard.tsx +125 -0
  36. package/src/components/home/HomeView.tsx +15 -1
  37. package/src/components/issues/IssuesPane.test.ts +142 -0
  38. package/src/components/issues/IssuesPane.tsx +142 -38
  39. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  40. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  41. package/src/components/resources/ResourcesView.tsx +9 -8
  42. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  43. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  44. package/src/components/settings/SettingsDialog.tsx +31 -19
  45. package/src/components/timeline/TimelineView.tsx +17 -3
  46. package/src/components/ui/command-items.ts +222 -98
  47. package/src/components/workload/WorkloadView.tsx +16 -83
  48. package/src/context/ConnectionContext.test.ts +39 -0
  49. package/src/context/ConnectionContext.tsx +155 -51
  50. package/src/context/DiagnoseCustomization.tsx +1 -1
  51. package/src/utils/shell-safe.test.ts +55 -0
  52. 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.0",
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.8.15",
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.59.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.1",
61
- "@tanstack/react-query": "^5.101.2",
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.2",
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.1",
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 = () => setShowSettings(true)
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: () => setShowSettings(true),
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={() => setShowSettings(true)}
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={() => setShowSettings(true)}
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
+ })
@@ -1,28 +1,67 @@
1
- import { useQuery } from '@tanstack/react-query'
2
- import type { APIResource } from '../types'
3
- import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
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 { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute, formatGroupName, shortenGroupName, getKindLabel, getKindPlural } from '@skyhook-io/k8s-ui'
7
- export type { ResourceCategory } from '@skyhook-io/k8s-ui'
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.json().catch(() => ({ error: 'Unknown error' }))
16
- throw new Error(error.error || `HTTP ${response.status}`)
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: ['api-resources'],
25
- queryFn: () => fetchJSON('/api-resources'),
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
+ });