@skyhook-io/radar-app 1.9.1 → 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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +42 -7
  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 +2884 -2075
  7. package/src/api/diagnose.ts +5 -0
  8. package/src/components/ConnectionErrorView.test.tsx +88 -0
  9. package/src/components/ConnectionErrorView.tsx +128 -22
  10. package/src/components/capacity/CapacityActivity.tsx +787 -0
  11. package/src/components/capacity/CapacityDemand.tsx +961 -0
  12. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  14. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  15. package/src/components/capacity/CapacityView.tsx +85 -0
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  17. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  18. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  19. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  20. package/src/components/capacity/podDemandGate.test.ts +47 -0
  21. package/src/components/capacity/podDemandGate.ts +22 -0
  22. package/src/components/capacity/schedulingBar.test.ts +244 -0
  23. package/src/components/capacity/shared.tsx +1841 -0
  24. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  25. package/src/components/diagnose/DiagnoseContext.tsx +51 -10
  26. package/src/components/diagnose/DiagnoseSurface.tsx +20 -7
  27. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  28. package/src/components/diagnose/agentCatalog.ts +30 -0
  29. package/src/components/home/CapacityCard.test.tsx +150 -0
  30. package/src/components/home/CapacityCard.tsx +125 -0
  31. package/src/components/home/HomeView.tsx +15 -1
  32. package/src/components/issues/IssuesPane.test.ts +142 -0
  33. package/src/components/issues/IssuesPane.tsx +142 -38
  34. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  35. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  36. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  37. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  38. package/src/components/ui/command-items.ts +222 -98
  39. package/src/components/workload/WorkloadView.tsx +16 -83
  40. package/src/context/ConnectionContext.test.ts +39 -0
  41. package/src/context/ConnectionContext.tsx +155 -51
  42. package/src/utils/shell-safe.test.ts +55 -0
  43. 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.1",
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",
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'
@@ -56,7 +57,7 @@ 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'
@@ -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.
@@ -785,7 +799,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
785
799
  const VIEW_SHORTCUT_KEYS: Record<ExtendedMainView, string> = {
786
800
  home: 'g h', resources: 'g r', issues: 'g i', topology: 'g t',
787
801
  applications: 'g a', timeline: 'g l', traffic: 'g f', helm: 'g m',
788
- gitops: 'g o', checks: 'g u', cost: 'g c',
802
+ gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p',
789
803
  // Non-rail views (reachable via deep links / actions, not the rail) get no
790
804
  // dedicated mnemonic — listed for exhaustiveness so the type stays total.
791
805
  workload: '', compare: '', helmCompare: '',
@@ -965,9 +979,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
965
979
  const fastInvalidationRef = useRef<{
966
980
  changedKinds: Set<string> // every changed kind (any op) → detail drawer
967
981
  structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
982
+ environmentNamespaces: Set<string>
983
+ environmentPods: Map<string, Set<string>>
968
984
  secretsChanged: boolean
969
985
  timer: number | null
970
- }>({ 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 })
971
987
  const slowInvalidationRef = useRef<{
972
988
  updatedKinds: Set<string> // update-only churn → throttled list + dashboard
973
989
  timer: number | null
@@ -1002,6 +1018,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1002
1018
  fast.changedKinds.add(kind)
1003
1019
  if (structural) fast.structuralKinds.add(kind)
1004
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
+ }
1005
1027
 
1006
1028
  const slow = slowInvalidationRef.current
1007
1029
  if (!structural) slow.updatedKinds.add(kind)
@@ -1023,11 +1045,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1023
1045
  if (f.secretsChanged) {
1024
1046
  queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
1025
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
+ }
1026
1056
  // GitOps behavior unchanged from before — refreshes every batch when a
1027
1057
  // GitOps view is mounted (Phase 2 will make this relevance-aware).
1028
1058
  queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
1029
1059
  queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
1030
- 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 }
1031
1061
  }, 3000)
1032
1062
  }
1033
1063
 
@@ -1053,7 +1083,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1053
1083
  if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
1054
1084
  if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
1055
1085
  if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
1056
- 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 }
1057
1087
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1058
1088
  timelineInvalidationRef.current = { timer: null }
1059
1089
  }, [])
@@ -1076,7 +1106,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1076
1106
  if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
1077
1107
  if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
1078
1108
  if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
1079
- 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 }
1080
1110
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1081
1111
  timelineInvalidationRef.current = { timer: null }
1082
1112
 
@@ -1729,6 +1759,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1729
1759
  { view: 'home' as const, icon: Home, label: 'Home' },
1730
1760
  { view: 'topology' as const, icon: Network, label: 'Topology' },
1731
1761
  { view: 'resources' as const, icon: List, label: 'Resources' },
1762
+ { view: 'capacity' as const, icon: Gauge, label: 'Capacity' },
1732
1763
  { view: 'timeline' as const, icon: Clock, label: 'Timeline' },
1733
1764
  { view: 'helm' as const, icon: Package, label: 'Helm' },
1734
1765
  { view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
@@ -2238,6 +2269,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2238
2269
  <CostView namespaces={namespaces} onBack={() => setMainView('home')} onOpenResource={navigateToResource} />
2239
2270
  )}
2240
2271
 
2272
+ {mainView === 'capacity' && (
2273
+ <CapacityView onOpenResource={navigateToResource} />
2274
+ )}
2275
+
2241
2276
  {/* Takeover splash. When the host claims the current view via
2242
2277
  fleetTakeoverHref, the redirect effect above is mid-flight — render a
2243
2278
  brief splash instead of the inline view (which would flash + fire its
@@ -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
+ });