@skyhook-io/radar-app 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +35 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +246 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  16. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  17. package/src/components/cost/CostTrendChart.test.ts +65 -0
  18. package/src/components/cost/CostTrendChart.tsx +107 -17
  19. package/src/components/cost/CostView.test.ts +36 -1
  20. package/src/components/cost/CostView.tsx +133 -51
  21. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  22. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  23. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  24. package/src/components/cost/source.test.ts +33 -0
  25. package/src/components/cost/source.ts +100 -0
  26. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  27. package/src/components/diagnose/parts.test.tsx +12 -4
  28. package/src/components/diagnose/parts.tsx +19 -15
  29. package/src/components/gitops/GitOpsView.tsx +8 -3
  30. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  31. package/src/components/helm/OwnedResources.tsx +10 -2
  32. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  33. package/src/components/home/ClusterHealthCard.tsx +60 -1
  34. package/src/components/home/CostCard.tsx +3 -2
  35. package/src/components/home/HomeView.tsx +36 -11
  36. package/src/components/home/MCPSetupDialog.tsx +5 -4
  37. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  38. package/src/components/home/RadarVersionLine.tsx +137 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  40. package/src/components/resources/ResourcesView.tsx +31 -8
  41. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  42. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  43. package/src/components/rightsizing/copy.test.ts +19 -0
  44. package/src/components/settings/SettingsDialog.tsx +687 -107
  45. package/src/components/settings/settings-state.test.ts +42 -0
  46. package/src/components/settings/settings-state.ts +39 -0
  47. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  48. package/src/components/ui/ErrorBoundary.tsx +17 -2
  49. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  50. package/src/components/ui/UpdateNotification.tsx +6 -2
  51. package/src/components/workload/WorkloadView.test.ts +60 -0
  52. package/src/components/workload/WorkloadView.tsx +270 -29
  53. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  54. package/src/contexts/CapabilitiesContext.tsx +7 -3
  55. package/src/index.css +11 -1
  56. package/src/utils/navigation.test.ts +45 -0
  57. package/src/utils/navigation.ts +5 -5
  58. package/src/utils/topology-selection.ts +3 -2
  59. package/src/utils/version.test.ts +37 -0
  60. package/src/utils/version.ts +56 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.12.2",
3
+ "version": "1.13.0",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,13 +35,13 @@
35
35
  "diff": "^9.0.0",
36
36
  "monaco-editor": "0.55.1",
37
37
  "react-markdown": "^10.1.0",
38
- "react-virtuoso": "^4.18.11",
38
+ "react-virtuoso": "^4.18.12",
39
39
  "remark-gfm": "^4.0.1",
40
40
  "shiki": "^4.4.2",
41
41
  "yaml": "^2.9.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@skyhook-io/k8s-ui": ">=1.14.0",
44
+ "@skyhook-io/k8s-ui": ">=1.13.4",
45
45
  "@tanstack/react-query": ">=5",
46
46
  "@xyflow/react": ">=12.0.0",
47
47
  "clsx": ">=2",
@@ -69,8 +69,8 @@
69
69
  "eslint": "^10.8.0",
70
70
  "eslint-plugin-react-hooks": "^7.1.1",
71
71
  "eslint-plugin-react-refresh": "^0.5.3",
72
- "globals": "^17.9.0",
73
- "lucide-react": "^1.33.0",
72
+ "globals": "^17.11.0",
73
+ "lucide-react": "^1.37.0",
74
74
  "postcss": "^8.5.26",
75
75
  "prettier": "^3.9.5",
76
76
  "react": "^19.2.8",
@@ -80,7 +80,7 @@
80
80
  "tailwindcss": "^4.3.3",
81
81
  "typescript": "^6.0.2",
82
82
  "typescript-eslint": "^8.67.0",
83
- "vite": "^8.2.1"
83
+ "vite": "^8.2.2"
84
84
  },
85
85
  "sideEffects": [
86
86
  "*.css"
package/src/App.tsx CHANGED
@@ -64,7 +64,7 @@ import { Tooltip } from './components/ui/Tooltip'
64
64
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
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
- import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
67
+ import { kindToPluralWithGroup, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
68
68
  import { findSelectedTopologyNode } from './utils/topology-selection'
69
69
  import { type OmnibarHandle } from './components/ui/Omnibar'
70
70
  import { RadarOmnibar } from './components/ui/RadarOmnibar'
@@ -604,7 +604,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
604
604
  const peekOwnerKeyRef = useRef<string | null>(null)
605
605
  const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
606
606
  const currentResourceGroup = searchParams.get('apiGroup') ?? ''
607
- const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
607
+ const selectedResourceKindSlug = selectedResource ? kindToPluralWithGroup(selectedResource.kind, selectedResource.group ?? '').toLowerCase() : ''
608
608
  const selectedResourceGroup = selectedResource?.group ?? ''
609
609
  const selectedResourceRouteMismatch = mainView === 'resources' && !!selectedResource && (
610
610
  selectedResourceKindSlug !== currentResourceKindSlug ||
@@ -707,7 +707,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
707
707
  // URL (?resource=ns/name) — the same deep-link shape the resources view
708
708
  // round-trips — so refresh/share keeps the drawer open instead of dropping it.
709
709
  const navigateToResourceList = useCallback((resource: SelectedResource) => {
710
- const pluralKind = kindToPlural(resource.kind)
710
+ const pluralKind = kindToPluralWithGroup(resource.kind, resource.group ?? '')
711
711
  setSelectedResource({ ...resource, kind: pluralKind })
712
712
  const newParams = new URLSearchParams(searchParams)
713
713
  newParams.delete('kind')
@@ -1027,8 +1027,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1027
1027
  // Skip K8s Event kind — informational, not resource mutations
1028
1028
  if (event.kind === 'Event') return
1029
1029
 
1030
- const kind = kindToPlural(event.kind)
1030
+ const kind = kindToPluralWithGroup(event.kind, event.group ?? '')
1031
1031
  const structural = event.operation === 'add' || event.operation === 'delete'
1032
+ const applicationWorkload = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind)
1032
1033
 
1033
1034
  const fast = fastInvalidationRef.current
1034
1035
  fast.changedKinds.add(kind)
@@ -1042,7 +1043,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1042
1043
  }
1043
1044
 
1044
1045
  const slow = slowInvalidationRef.current
1045
- if (!structural) slow.updatedKinds.add(kind)
1046
+ if (!structural || applicationWorkload) slow.updatedKinds.add(kind)
1046
1047
 
1047
1048
  // FAST tier — membership-sensitive + cheap, bounded 3s latency.
1048
1049
  if (fast.timer === null) {
@@ -1077,14 +1078,17 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1077
1078
  }, 3000)
1078
1079
  }
1079
1080
 
1080
- // SLOW tier — throttle the expensive queries for status-only churn. Only
1081
- // updates schedule it; structural changes are fully handled by the fast tier.
1082
- if (!structural && slow.timer === null) {
1081
+ // SLOW tier — throttle expensive status churn. Workload membership changes
1082
+ // also pass through here so the Applications refetch lands after its cache TTL.
1083
+ if ((!structural || applicationWorkload) && slow.timer === null) {
1083
1084
  slow.timer = window.setTimeout(() => {
1084
1085
  const s = slowInvalidationRef.current
1085
1086
  for (const k of s.updatedKinds) {
1086
1087
  queryClient.invalidateQueries({ queryKey: ['resources', k] })
1087
1088
  }
1089
+ if ([...s.updatedKinds].some((k) => ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(k))) {
1090
+ queryClient.invalidateQueries({ queryKey: ['applications'] })
1091
+ }
1088
1092
  queryClient.invalidateQueries({ queryKey: ['dashboard'] }) // health reflects status updates
1089
1093
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1090
1094
  }, 15000)
@@ -1250,9 +1254,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1250
1254
  // Skip Internet node - it's not a real resource
1251
1255
  if (node.kind === 'Internet') return
1252
1256
 
1253
- // For PodGroup, we can't open a single resource drawer
1254
- // TODO: Could show a list of pods in the group
1255
- if (node.kind === 'PodGroup') return
1257
+ const nodeGroup = apiVersionToGroup(node.data.apiVersion as string | undefined)
1258
+ // Radar's topology PodGroup is a virtual pod aggregate. A Kubernetes
1259
+ // scheduling.k8s.io PodGroup carries apiVersion and is a real resource.
1260
+ if (node.kind === 'PodGroup' && !nodeGroup) return
1256
1261
 
1257
1262
  const namespace = (node.data.namespace as string) || ''
1258
1263
  // GitOps CRs (Application/Kustomization/HelmRelease/etc.) have a dedicated
@@ -1266,10 +1271,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1266
1271
  }
1267
1272
 
1268
1273
  navigateToResource({
1269
- kind: kindToPlural(node.kind),
1274
+ kind: kindToPluralWithGroup(node.kind, nodeGroup),
1270
1275
  namespace,
1271
1276
  name: node.name,
1272
- group: apiVersionToGroup(node.data.apiVersion as string | undefined),
1277
+ group: nodeGroup,
1273
1278
  })
1274
1279
  }, [navigate, navigateToResource])
1275
1280
 
@@ -2031,7 +2036,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2031
2036
  retained background list out of the focus order + a11y tree (the visual
2032
2037
  cover already blocks pointer events). */}
2033
2038
  {contentReady && <div className="flex-1 flex overflow-hidden" inert={expandedView}>
2034
- <ErrorBoundary>
2039
+ {/* Search included, not just the path: selection inside a view rides in
2040
+ the query (?resource=, ?release=), so a path-only key would still
2041
+ strand a crash on the view that produced it. */}
2042
+ <ErrorBoundary resetKey={location.pathname + location.search}>
2035
2043
  {/* Home dashboard */}
2036
2044
  {mainView === 'home' && (
2037
2045
  <HomeView
@@ -2039,6 +2047,17 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2039
2047
  topology={topology}
2040
2048
  fallbackClusterLoadState={showHomeClusterLoadFallback ? clusterLoadState : undefined}
2041
2049
  onNavigateToView={setMainView}
2050
+ onNavigateToHelmRelease={navCustomization.embedded ? undefined : navigateToHelmRelease}
2051
+ onNavigateToManagerPath={navCustomization.embedded || takeover.gitops ? undefined : (path) => navigate(path)}
2052
+ // Upgrade impact lives under /checks, which a Cloud host takes
2053
+ // over wholesale — its fleet pages have no upgrade sub-route, so
2054
+ // the version line stays plain text there.
2055
+ onNavigateToUpgradeImpact={takeover.checks ? undefined : () => {
2056
+ const newParams = new URLSearchParams()
2057
+ const globalNamespaces = searchParams.get('namespaces')
2058
+ if (globalNamespaces) newParams.set('namespaces', globalNamespaces)
2059
+ navigate({ pathname: '/checks/upgrade', search: newParams.toString() })
2060
+ }}
2042
2061
  onNavigateToResourceKind={(kind, apiGroup, filters) => {
2043
2062
  // Navigate to resources view with kind in URL path
2044
2063
  console.debug('[filters] App.onNavigateToResourceKind:', { kind, apiGroup, filters })
@@ -2375,7 +2394,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2375
2394
  // Drill into a related resource while expanded: stay in the over-list
2376
2395
  // overlay for the new resource (pushed, so Back walks resource→resource
2377
2396
  // still expanded). The backdrop list follows to the new kind.
2378
- const pluralKind = kindToPlural(resource.kind)
2397
+ const pluralKind = kindToPluralWithGroup(resource.kind, resource.group ?? '')
2379
2398
  setSelectedResource({ ...resource, kind: pluralKind })
2380
2399
  const p = new URLSearchParams()
2381
2400
  const ns = searchParams.get('namespaces')
@@ -2488,6 +2507,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2488
2507
  open={showSettings}
2489
2508
  initialSection={settingsSection}
2490
2509
  onClose={() => setShowSettings(false)}
2510
+ onNavigateToResource={navigateToResourceList}
2491
2511
  />
2492
2512
 
2493
2513
  {/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
@@ -0,0 +1,63 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { ApiError, fetchResourceWithRelationships, fetchWorkloadImages, setWorkloadImages } from './client'
4
+
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals()
7
+ })
8
+
9
+ describe('workload image API', () => {
10
+ it('loads the exact referenced workload and API group for ownership checks', async () => {
11
+ const response = { resource: { kind: 'Deployment' }, relationships: { managedBy: [] } }
12
+ const fetchMock = vi.fn((input: RequestInfo | URL) => {
13
+ expect(input).toBe('/api/resources/deployments/prod/shared?group=apps')
14
+ return Promise.resolve(new Response(JSON.stringify(response), { status: 200 }))
15
+ })
16
+ vi.stubGlobal('fetch', fetchMock)
17
+
18
+ await expect(
19
+ fetchResourceWithRelationships('deployments', 'prod', 'shared', 'apps'),
20
+ ).resolves.toEqual(response)
21
+ })
22
+
23
+ it('loads the authoritative image inventory', async () => {
24
+ const inventory = {
25
+ target: { group: 'apps', resource: 'deployments', kind: 'Deployment', namespace: 'prod', name: 'web' },
26
+ containers: [{ type: 'container', name: 'app', image: 'repo/app:v1' }],
27
+ behavior: { type: 'rolling' },
28
+ }
29
+ const fetchMock = vi.fn((input: RequestInfo | URL) => {
30
+ expect(input).toBe('/api/workloads/deployments/prod/web/images')
31
+ return Promise.resolve(new Response(JSON.stringify(inventory), { status: 200 }))
32
+ })
33
+ vi.stubGlobal('fetch', fetchMock)
34
+
35
+ await expect(fetchWorkloadImages('deployments', 'prod', 'web')).resolves.toEqual(inventory)
36
+ expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/workloads/deployments/prod/web/images')
37
+ })
38
+
39
+ it('posts compare-and-swap image updates and preserves conflict status', async () => {
40
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
41
+ expect(input).toBe('/api/workloads/deployments/prod/web/images')
42
+ expect(init?.method).toBe('POST')
43
+ return Promise.resolve(new Response(JSON.stringify({
44
+ error: 'image for container "app" changed; review the latest images before applying',
45
+ }), { status: 409, headers: { 'Content-Type': 'application/json' } }))
46
+ })
47
+ vi.stubGlobal('fetch', fetchMock)
48
+
49
+ const promise = setWorkloadImages({
50
+ kind: 'deployments',
51
+ namespace: 'prod',
52
+ name: 'web',
53
+ updates: [{ type: 'container', name: 'app', previousImage: 'repo/app:v1', image: 'repo/app:v2' }],
54
+ })
55
+ await expect(promise).rejects.toMatchObject({ status: 409 } satisfies Partial<ApiError>)
56
+
57
+ const init = fetchMock.mock.calls[0]?.[1] as RequestInit
58
+ expect(init.method).toBe('POST')
59
+ expect(JSON.parse(String(init.body))).toEqual({
60
+ updates: [{ type: 'container', name: 'app', previousImage: 'repo/app:v1', image: 'repo/app:v2' }],
61
+ })
62
+ })
63
+ })