@skyhook-io/radar-app 1.12.2 → 1.12.3
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 +2 -2
- package/src/App.tsx +34 -15
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +208 -33
- 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/diagnose/DiagnoseSurface.tsx +14 -10
- 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/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/settings/SettingsDialog.tsx +21 -4
- 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/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.12.
|
|
3
|
+
"version": "1.12.3",
|
|
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.13.4",
|
|
45
45
|
"@tanstack/react-query": ">=5",
|
|
46
46
|
"@xyflow/react": ">=12.0.0",
|
|
47
47
|
"clsx": ">=2",
|
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')
|
|
@@ -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
|
+
})
|
package/src/api/client.ts
CHANGED
|
@@ -11,6 +11,9 @@ import type {
|
|
|
11
11
|
CapacityOverviewResponse,
|
|
12
12
|
CapacityPoolDetailResponse,
|
|
13
13
|
CapacityPoolListResponse,
|
|
14
|
+
SetWorkloadImagesResult,
|
|
15
|
+
WorkloadImageInventory,
|
|
16
|
+
WorkloadImageUpdate,
|
|
14
17
|
YamlDocumentIdentity,
|
|
15
18
|
YamlSchemaLoadResult,
|
|
16
19
|
} from '@skyhook-io/k8s-ui'
|
|
@@ -52,8 +55,9 @@ import type {
|
|
|
52
55
|
PodEnvironmentRevealResponse,
|
|
53
56
|
} from '../types'
|
|
54
57
|
import type { GitOpsOperationResponse } from '../types/gitops'
|
|
55
|
-
import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
|
|
56
|
-
import { pluralToKind } from '../utils/navigation'
|
|
58
|
+
import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
|
|
59
|
+
import { apiVersionToGroup, pluralToKind } from '../utils/navigation'
|
|
60
|
+
import type { DeploymentMode } from '../types'
|
|
57
61
|
|
|
58
62
|
// Auto-refresh cadences (ms) — named constants for each polled hook's
|
|
59
63
|
// refetchInterval below, so the poll rate reads clearly at each call site.
|
|
@@ -292,6 +296,9 @@ export interface DashboardCluster {
|
|
|
292
296
|
platform: string;
|
|
293
297
|
version: string;
|
|
294
298
|
connected: boolean;
|
|
299
|
+
// Newest Kubernetes minor the upgrade-impact catalog covers (e.g. "1.37").
|
|
300
|
+
// Static catalog fact — safe to compare against `version` without a scan.
|
|
301
|
+
upgradeReviewedThrough?: string;
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
export interface DashboardHealth {
|
|
@@ -1510,13 +1517,68 @@ export interface VersionInfo {
|
|
|
1510
1517
|
error?: string;
|
|
1511
1518
|
}
|
|
1512
1519
|
|
|
1520
|
+
const UPDATE_CHECK_STORAGE_KEY_PREFIX = 'radar-update-check';
|
|
1521
|
+
|
|
1522
|
+
export function utcDay(now: Date): string {
|
|
1523
|
+
return now.toISOString().slice(0, 10)
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
export function markDailyUpdateCheckAttempt(
|
|
1527
|
+
storage: Pick<Storage, 'getItem' | 'setItem'>,
|
|
1528
|
+
apiBase: string,
|
|
1529
|
+
now: Date,
|
|
1530
|
+
): boolean {
|
|
1531
|
+
const storageKey = `${UPDATE_CHECK_STORAGE_KEY_PREFIX}:${apiBase}`
|
|
1532
|
+
const day = utcDay(now)
|
|
1533
|
+
try {
|
|
1534
|
+
if (storage.getItem(storageKey) === day) return false
|
|
1535
|
+
|
|
1536
|
+
storage.setItem(storageKey, day)
|
|
1537
|
+
return true
|
|
1538
|
+
} catch {
|
|
1539
|
+
return false
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function markUpdateCheckAttempt(): boolean {
|
|
1544
|
+
try {
|
|
1545
|
+
return markDailyUpdateCheckAttempt(localStorage, getApiBase(), new Date())
|
|
1546
|
+
} catch {
|
|
1547
|
+
return false
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
export async function triggerDailyUpdateCheck(
|
|
1552
|
+
deploymentMode: DeploymentMode | undefined,
|
|
1553
|
+
): Promise<void> {
|
|
1554
|
+
if (deploymentMode !== 'in-cluster' || !markUpdateCheckAttempt()) return
|
|
1555
|
+
await fetch(apiUrl('/version-check/browser'), {
|
|
1556
|
+
method: 'POST',
|
|
1557
|
+
headers: getAuthHeaders(),
|
|
1558
|
+
credentials: getCredentialsMode(),
|
|
1559
|
+
keepalive: true,
|
|
1560
|
+
})
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1513
1563
|
export function useVersionCheck() {
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1564
|
+
const capabilities = useCapabilities()
|
|
1565
|
+
const apiBase = getApiBase()
|
|
1566
|
+
const deploymentMode = capabilities.data
|
|
1567
|
+
? (capabilities.data.deployment?.mode ?? 'local')
|
|
1568
|
+
: undefined
|
|
1569
|
+
|
|
1570
|
+
const query = useQuery<VersionInfo>({
|
|
1571
|
+
queryKey: ["version-check", apiBase],
|
|
1572
|
+
queryFn: () => fetchJSON('/version-check'),
|
|
1517
1573
|
staleTime: 60 * 60 * 1000, // 1 hour
|
|
1518
1574
|
retry: false, // Don't retry on failure
|
|
1519
1575
|
});
|
|
1576
|
+
|
|
1577
|
+
useEffect(() => {
|
|
1578
|
+
if (query.isSuccess) void triggerDailyUpdateCheck(deploymentMode).catch(() => {})
|
|
1579
|
+
}, [apiBase, deploymentMode, query.isSuccess])
|
|
1580
|
+
|
|
1581
|
+
return query;
|
|
1520
1582
|
}
|
|
1521
1583
|
|
|
1522
1584
|
// ============================================================================
|
|
@@ -1789,6 +1851,11 @@ export const cloudInstallActive = (state: CloudInstallState | undefined): boolea
|
|
|
1789
1851
|
export interface CloudConnectInfo {
|
|
1790
1852
|
assurances?: string[]
|
|
1791
1853
|
notice?: string
|
|
1854
|
+
// Free-tier terms as a prose fragment completing "Radar Cloud is ___."
|
|
1855
|
+
// (e.g. "free for 3 clusters"). Separate from assurances: chip copy and
|
|
1856
|
+
// sentence copy have different grammar, and reusing one as the other breaks
|
|
1857
|
+
// whenever either is reworded.
|
|
1858
|
+
freeTier?: string
|
|
1792
1859
|
}
|
|
1793
1860
|
|
|
1794
1861
|
// Deliberately a bare cross-origin GET: no credentials, no identifiers, no
|
|
@@ -1836,6 +1903,12 @@ export interface CloudConnectSelf {
|
|
|
1836
1903
|
deploymentName?: string
|
|
1837
1904
|
chart?: string
|
|
1838
1905
|
controller?: string
|
|
1906
|
+
controllerRef?: {
|
|
1907
|
+
group?: string
|
|
1908
|
+
kind: string
|
|
1909
|
+
namespace?: string
|
|
1910
|
+
name: string
|
|
1911
|
+
}
|
|
1839
1912
|
wizardUrl?: string
|
|
1840
1913
|
}
|
|
1841
1914
|
|
|
@@ -2219,25 +2292,32 @@ export function useArgoRevisionMetadata(
|
|
|
2219
2292
|
|
|
2220
2293
|
// Generic resource fetching - returns resource with relationships
|
|
2221
2294
|
// Uses '_' as placeholder for cluster-scoped resources (empty namespace)
|
|
2222
|
-
export function
|
|
2295
|
+
export function fetchResourceWithRelationships<T>(
|
|
2223
2296
|
kind: string,
|
|
2224
2297
|
namespace: string,
|
|
2225
2298
|
name: string,
|
|
2226
2299
|
group?: string,
|
|
2227
|
-
|
|
2228
|
-
) {
|
|
2229
|
-
// For cluster-scoped resources, use '_' as namespace placeholder
|
|
2300
|
+
): Promise<ResourceWithRelationships<T>> {
|
|
2230
2301
|
const ns = namespace || "_";
|
|
2231
2302
|
const params = new URLSearchParams();
|
|
2232
2303
|
if (group) params.set("group", group);
|
|
2233
2304
|
const queryString = params.toString();
|
|
2234
2305
|
|
|
2306
|
+
return fetchJSON(
|
|
2307
|
+
`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
export function useResource<T>(
|
|
2312
|
+
kind: string,
|
|
2313
|
+
namespace: string,
|
|
2314
|
+
name: string,
|
|
2315
|
+
group?: string,
|
|
2316
|
+
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
2317
|
+
) {
|
|
2235
2318
|
const query = useQuery<ResourceWithRelationships<T>>({
|
|
2236
2319
|
queryKey: ["resource", kind, namespace, name, group],
|
|
2237
|
-
queryFn: () =>
|
|
2238
|
-
fetchJSON(
|
|
2239
|
-
`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
|
|
2240
|
-
),
|
|
2320
|
+
queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
|
|
2241
2321
|
enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
|
|
2242
2322
|
refetchInterval: options?.refetchInterval,
|
|
2243
2323
|
});
|
|
@@ -2259,17 +2339,9 @@ export function useResourceWithRelationships<T>(
|
|
|
2259
2339
|
name: string,
|
|
2260
2340
|
group?: string,
|
|
2261
2341
|
) {
|
|
2262
|
-
const ns = namespace || "_";
|
|
2263
|
-
const params = new URLSearchParams();
|
|
2264
|
-
if (group) params.set("group", group);
|
|
2265
|
-
const queryString = params.toString();
|
|
2266
|
-
|
|
2267
2342
|
return useQuery<ResourceWithRelationships<T>>({
|
|
2268
2343
|
queryKey: ["resource", kind, namespace, name, group],
|
|
2269
|
-
queryFn: () =>
|
|
2270
|
-
fetchJSON(
|
|
2271
|
-
`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
|
|
2272
|
-
),
|
|
2344
|
+
queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
|
|
2273
2345
|
enabled: Boolean(kind && name),
|
|
2274
2346
|
});
|
|
2275
2347
|
}
|
|
@@ -3638,6 +3710,7 @@ export function useUpdateResource() {
|
|
|
3638
3710
|
queryKey: ["resources", variables.kind],
|
|
3639
3711
|
});
|
|
3640
3712
|
queryClient.invalidateQueries({ queryKey: ["topology"] });
|
|
3713
|
+
queryClient.invalidateQueries({ queryKey: ["applications"] });
|
|
3641
3714
|
},
|
|
3642
3715
|
});
|
|
3643
3716
|
}
|
|
@@ -3959,6 +4032,7 @@ export interface ApplyResourceResult {
|
|
|
3959
4032
|
name: string;
|
|
3960
4033
|
namespace: string;
|
|
3961
4034
|
kind: string;
|
|
4035
|
+
apiVersion: string;
|
|
3962
4036
|
created: boolean;
|
|
3963
4037
|
}
|
|
3964
4038
|
|
|
@@ -4346,6 +4420,90 @@ export function useRestartWorkload() {
|
|
|
4346
4420
|
});
|
|
4347
4421
|
}
|
|
4348
4422
|
|
|
4423
|
+
export function fetchWorkloadImages(
|
|
4424
|
+
kind: string,
|
|
4425
|
+
namespace: string,
|
|
4426
|
+
name: string,
|
|
4427
|
+
): Promise<WorkloadImageInventory> {
|
|
4428
|
+
return fetchJSON(`/workloads/${kind}/${namespace}/${name}/images`);
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
export function setWorkloadImages(params: {
|
|
4432
|
+
kind: string;
|
|
4433
|
+
namespace: string;
|
|
4434
|
+
name: string;
|
|
4435
|
+
updates: WorkloadImageUpdate[];
|
|
4436
|
+
}): Promise<SetWorkloadImagesResult> {
|
|
4437
|
+
return fetchJSON<SetWorkloadImagesResult>(
|
|
4438
|
+
`/workloads/${params.kind}/${params.namespace}/${params.name}/images`,
|
|
4439
|
+
{
|
|
4440
|
+
method: "POST",
|
|
4441
|
+
headers: { "Content-Type": "application/json" },
|
|
4442
|
+
body: JSON.stringify({ updates: params.updates }),
|
|
4443
|
+
},
|
|
4444
|
+
);
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
export function useSetWorkloadImages() {
|
|
4448
|
+
const queryClient = useQueryClient();
|
|
4449
|
+
|
|
4450
|
+
return useMutation({
|
|
4451
|
+
mutationFn: (params: {
|
|
4452
|
+
kind: string;
|
|
4453
|
+
namespace: string;
|
|
4454
|
+
name: string;
|
|
4455
|
+
updates: WorkloadImageUpdate[];
|
|
4456
|
+
}) => setWorkloadImages(params),
|
|
4457
|
+
meta: {
|
|
4458
|
+
successMessage: "Container images updated",
|
|
4459
|
+
},
|
|
4460
|
+
onSuccess: async (result, variables) => {
|
|
4461
|
+
await queryClient.cancelQueries({
|
|
4462
|
+
queryKey: ["resource", result.target.resource, result.target.namespace, result.target.name],
|
|
4463
|
+
});
|
|
4464
|
+
queryClient.setQueriesData<ResourceWithRelationships<Record<string, unknown>>>(
|
|
4465
|
+
{
|
|
4466
|
+
predicate: (query) => {
|
|
4467
|
+
const [scope, resource, namespace, name, group] = query.queryKey;
|
|
4468
|
+
return scope === "resource" &&
|
|
4469
|
+
resource === result.target.resource &&
|
|
4470
|
+
namespace === result.target.namespace &&
|
|
4471
|
+
name === result.target.name &&
|
|
4472
|
+
(group === result.target.group || group === undefined || group === "");
|
|
4473
|
+
},
|
|
4474
|
+
},
|
|
4475
|
+
(current) => {
|
|
4476
|
+
if (!current) return current;
|
|
4477
|
+
const apiVersion = current.resource.apiVersion;
|
|
4478
|
+
if (typeof apiVersion !== "string" || apiVersionToGroup(apiVersion) !== result.target.group) {
|
|
4479
|
+
return current;
|
|
4480
|
+
}
|
|
4481
|
+
return { ...current, resource: result.object };
|
|
4482
|
+
},
|
|
4483
|
+
);
|
|
4484
|
+
if (result.target.resource !== variables.kind || result.target.name !== variables.name) {
|
|
4485
|
+
queryClient.invalidateQueries({
|
|
4486
|
+
queryKey: ["resource", variables.kind, variables.namespace, variables.name],
|
|
4487
|
+
});
|
|
4488
|
+
}
|
|
4489
|
+
queryClient.invalidateQueries({ queryKey: ["resources", variables.kind] });
|
|
4490
|
+
if (result.target.resource !== variables.kind) {
|
|
4491
|
+
queryClient.invalidateQueries({ queryKey: ["resources", result.target.resource] });
|
|
4492
|
+
}
|
|
4493
|
+
queryClient.invalidateQueries({ queryKey: ["applications"] });
|
|
4494
|
+
queryClient.invalidateQueries({
|
|
4495
|
+
queryKey: [
|
|
4496
|
+
"workload-revisions",
|
|
4497
|
+
variables.kind,
|
|
4498
|
+
variables.namespace,
|
|
4499
|
+
variables.name,
|
|
4500
|
+
],
|
|
4501
|
+
});
|
|
4502
|
+
queryClient.invalidateQueries({ queryKey: ["topology"] });
|
|
4503
|
+
},
|
|
4504
|
+
});
|
|
4505
|
+
}
|
|
4506
|
+
|
|
4349
4507
|
// Scale a workload (Deployment, StatefulSet)
|
|
4350
4508
|
export function useScaleWorkload() {
|
|
4351
4509
|
const queryClient = useQueryClient();
|
|
@@ -4509,6 +4667,7 @@ export interface RolloutCapabilities {
|
|
|
4509
4667
|
skipStep: boolean;
|
|
4510
4668
|
rollback: boolean;
|
|
4511
4669
|
restart: boolean;
|
|
4670
|
+
setImage?: boolean;
|
|
4512
4671
|
strategy: string;
|
|
4513
4672
|
terminating: boolean;
|
|
4514
4673
|
}
|
|
@@ -4525,33 +4684,35 @@ export function useRolloutCapabilities(
|
|
|
4525
4684
|
});
|
|
4526
4685
|
}
|
|
4527
4686
|
|
|
4687
|
+
// Fallbacks only. The server reports what it actually did — including when it found
|
|
4688
|
+
// nothing to do — so its message is preferred over anything asserted here.
|
|
4528
4689
|
const ROLLOUT_ACTION_MESSAGES: Record<
|
|
4529
4690
|
RolloutAction,
|
|
4530
4691
|
{ errorMessage: string; successMessage: string }
|
|
4531
4692
|
> = {
|
|
4532
4693
|
abort: {
|
|
4533
4694
|
errorMessage: "Failed to abort rollout",
|
|
4534
|
-
successMessage: "
|
|
4695
|
+
successMessage: "Abort sent",
|
|
4535
4696
|
},
|
|
4536
4697
|
retry: {
|
|
4537
4698
|
errorMessage: "Failed to retry rollout",
|
|
4538
|
-
successMessage: "
|
|
4699
|
+
successMessage: "Retry sent",
|
|
4539
4700
|
},
|
|
4540
4701
|
promote: {
|
|
4541
4702
|
errorMessage: "Failed to promote rollout",
|
|
4542
|
-
successMessage: "
|
|
4703
|
+
successMessage: "Promote sent",
|
|
4543
4704
|
},
|
|
4544
4705
|
"promote-full": {
|
|
4545
4706
|
errorMessage: "Failed to promote rollout",
|
|
4546
|
-
successMessage: "
|
|
4707
|
+
successMessage: "Promote full sent",
|
|
4547
4708
|
},
|
|
4548
4709
|
"skip-step": {
|
|
4549
4710
|
errorMessage: "Failed to skip step",
|
|
4550
|
-
successMessage: "
|
|
4711
|
+
successMessage: "Skip step sent",
|
|
4551
4712
|
},
|
|
4552
4713
|
};
|
|
4553
4714
|
|
|
4554
|
-
export function useRolloutAction() {
|
|
4715
|
+
export function useRolloutAction(options?: { reportErrors?: boolean }) {
|
|
4555
4716
|
const queryClient = useQueryClient();
|
|
4556
4717
|
return useMutation({
|
|
4557
4718
|
mutationFn: async ({
|
|
@@ -4571,14 +4732,26 @@ export function useRolloutAction() {
|
|
|
4571
4732
|
const error = await response
|
|
4572
4733
|
.json()
|
|
4573
4734
|
.catch(() => ({ error: "Unknown error" }));
|
|
4574
|
-
|
|
4735
|
+
// The code, not the status, says whether retrying can help: a lost cluster
|
|
4736
|
+
// connection answers 503 on this route too.
|
|
4737
|
+
throw new ApiError(
|
|
4738
|
+
error.error || `HTTP ${response.status}`,
|
|
4739
|
+
response.status,
|
|
4740
|
+
error,
|
|
4741
|
+
);
|
|
4575
4742
|
}
|
|
4576
4743
|
return response.json();
|
|
4577
4744
|
},
|
|
4578
|
-
// meta is static, so per-action success wording goes through onSuccess.
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4745
|
+
// meta is static, so per-action success wording goes through onSuccess. Callers that
|
|
4746
|
+
// render the failure themselves opt out, so the toast handler stays silent.
|
|
4747
|
+
meta:
|
|
4748
|
+
options?.reportErrors === false
|
|
4749
|
+
? undefined
|
|
4750
|
+
: { errorMessage: "Rollout action failed" },
|
|
4751
|
+
onSuccess: (data, variables) => {
|
|
4752
|
+
showApiSuccess(
|
|
4753
|
+
data?.message || ROLLOUT_ACTION_MESSAGES[variables.action].successMessage,
|
|
4754
|
+
);
|
|
4582
4755
|
queryClient.invalidateQueries({ queryKey: ["resources", "rollouts"] });
|
|
4583
4756
|
queryClient.invalidateQueries({
|
|
4584
4757
|
queryKey: ["resource", "rollouts", variables.namespace, variables.name],
|
|
@@ -5966,6 +6139,8 @@ export interface NamespaceScope {
|
|
|
5966
6139
|
*/
|
|
5967
6140
|
mode: "cluster-wide" | "namespace" | "restricted";
|
|
5968
6141
|
accessibleNamespaces: string[];
|
|
6142
|
+
/** Namespaces where Radar cannot list pods or deployments. */
|
|
6143
|
+
deniedNamespaces: string[];
|
|
5969
6144
|
/** false when accessibleNamespaces is a best-effort short list (no list perm). */
|
|
5970
6145
|
authoritative: boolean;
|
|
5971
6146
|
/** false when clearing would leave no usable namespace fallback. */
|
|
@@ -10,8 +10,8 @@ describe('formatApplyResourceError', () => {
|
|
|
10
10
|
failedIndex: 2,
|
|
11
11
|
total: 4,
|
|
12
12
|
results: [
|
|
13
|
-
{ kind: 'Namespace', namespace: '', name: 'checkout', created: true },
|
|
14
|
-
{ kind: 'Deployment', namespace: 'checkout', name: 'api', created: true },
|
|
13
|
+
{ apiVersion: 'v1', kind: 'Namespace', namespace: '', name: 'checkout', created: true },
|
|
14
|
+
{ apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'checkout', name: 'api', created: true },
|
|
15
15
|
],
|
|
16
16
|
},
|
|
17
17
|
422,
|
|
@@ -26,7 +26,7 @@ describe('formatApplyResourceError', () => {
|
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
it('preserves structured partial-apply results for recovery', () => {
|
|
29
|
-
const result = { kind: 'Namespace', namespace: '', name: 'checkout', created: true }
|
|
29
|
+
const result = { apiVersion: 'v1', kind: 'Namespace', namespace: '', name: 'checkout', created: true }
|
|
30
30
|
const error = new ApplyResourceError(
|
|
31
31
|
{
|
|
32
32
|
error: 'document 2: admission denied',
|