@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.
- package/package.json +6 -6
- package/src/App.tsx +35 -15
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +246 -39
- package/src/api/client.yaml.test.ts +3 -3
- package/src/api/version-check.test.ts +78 -0
- package/src/components/CloudConnectFlow.tsx +46 -26
- package/src/components/CloudFunnelButton.tsx +166 -129
- package/src/components/ConnectionErrorView.test.tsx +21 -1
- package/src/components/ConnectionErrorView.tsx +7 -8
- package/src/components/applications/ApplicationsView.tsx +10 -9
- package/src/components/audit/AuditView.tsx +6 -3
- package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
- package/src/components/audit/UpgradeReadinessView.tsx +19 -9
- package/src/components/cost/ApplicationCostTab.test.ts +45 -0
- package/src/components/cost/ApplicationCostTab.tsx +115 -64
- package/src/components/cost/CostTrendChart.test.ts +65 -0
- package/src/components/cost/CostTrendChart.tsx +107 -17
- package/src/components/cost/CostView.test.ts +36 -1
- package/src/components/cost/CostView.tsx +133 -51
- package/src/components/cost/CurrentAllocationUse.tsx +8 -4
- package/src/components/cost/WorkloadCostTab.test.ts +50 -0
- package/src/components/cost/WorkloadCostTab.tsx +109 -61
- package/src/components/cost/source.test.ts +33 -0
- package/src/components/cost/source.ts +100 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +19 -15
- package/src/components/gitops/GitOpsView.tsx +8 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
- package/src/components/helm/OwnedResources.tsx +10 -2
- package/src/components/home/ClusterHealthCard.test.ts +31 -0
- package/src/components/home/ClusterHealthCard.tsx +60 -1
- package/src/components/home/CostCard.tsx +3 -2
- package/src/components/home/HomeView.tsx +36 -11
- package/src/components/home/MCPSetupDialog.tsx +5 -4
- package/src/components/home/RadarVersionLine.test.tsx +145 -0
- package/src/components/home/RadarVersionLine.tsx +137 -0
- package/src/components/resources/PodFilesystemModal.tsx +54 -2
- package/src/components/resources/ResourcesView.tsx +31 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
- package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +687 -107
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- package/src/components/ui/ErrorBoundary.test.tsx +55 -0
- package/src/components/ui/ErrorBoundary.tsx +17 -2
- package/src/components/ui/UpdateNotification.test.tsx +49 -0
- package/src/components/ui/UpdateNotification.tsx +6 -2
- package/src/components/workload/WorkloadView.test.ts +60 -0
- package/src/components/workload/WorkloadView.tsx +270 -29
- package/src/contexts/CapabilitiesContext.test.tsx +29 -0
- package/src/contexts/CapabilitiesContext.tsx +7 -3
- package/src/index.css +11 -1
- package/src/utils/navigation.test.ts +45 -0
- package/src/utils/navigation.ts +5 -5
- package/src/utils/topology-selection.ts +3 -2
- package/src/utils/version.test.ts +37 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
73
|
-
"lucide-react": "^1.
|
|
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.
|
|
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 {
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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
|
|
1081
|
-
//
|
|
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
|
-
|
|
1254
|
-
//
|
|
1255
|
-
|
|
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:
|
|
1274
|
+
kind: kindToPluralWithGroup(node.kind, nodeGroup),
|
|
1270
1275
|
namespace,
|
|
1271
1276
|
name: node.name,
|
|
1272
|
-
group:
|
|
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
|
-
|
|
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 =
|
|
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
|
+
})
|