@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.
Files changed (41) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +34 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +208 -33
  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/diagnose/DiagnoseSurface.tsx +14 -10
  16. package/src/components/gitops/GitOpsView.tsx +8 -3
  17. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  18. package/src/components/helm/OwnedResources.tsx +10 -2
  19. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  20. package/src/components/home/ClusterHealthCard.tsx +60 -1
  21. package/src/components/home/HomeView.tsx +36 -11
  22. package/src/components/home/MCPSetupDialog.tsx +5 -4
  23. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  24. package/src/components/home/RadarVersionLine.tsx +137 -0
  25. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  26. package/src/components/resources/ResourcesView.tsx +31 -8
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  28. package/src/components/settings/SettingsDialog.tsx +21 -4
  29. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  30. package/src/components/ui/ErrorBoundary.tsx +17 -2
  31. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  32. package/src/components/ui/UpdateNotification.tsx +6 -2
  33. package/src/components/workload/WorkloadView.test.ts +60 -0
  34. package/src/components/workload/WorkloadView.tsx +270 -29
  35. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  36. package/src/contexts/CapabilitiesContext.tsx +7 -3
  37. package/src/utils/navigation.test.ts +45 -0
  38. package/src/utils/navigation.ts +5 -5
  39. package/src/utils/topology-selection.ts +3 -2
  40. package/src/utils/version.test.ts +37 -0
  41. 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.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.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",
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')
@@ -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
- return useQuery<VersionInfo>({
1515
- queryKey: ["version-check"],
1516
- queryFn: () => fetchJSON("/version-check"),
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 useResource<T>(
2295
+ export function fetchResourceWithRelationships<T>(
2223
2296
  kind: string,
2224
2297
  namespace: string,
2225
2298
  name: string,
2226
2299
  group?: string,
2227
- options?: { enabled?: boolean; refetchInterval?: number | false },
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: "Rollout aborted — traffic reverted to the stable version",
4695
+ successMessage: "Abort sent",
4535
4696
  },
4536
4697
  retry: {
4537
4698
  errorMessage: "Failed to retry rollout",
4538
- successMessage: "Rollout retried",
4699
+ successMessage: "Retry sent",
4539
4700
  },
4540
4701
  promote: {
4541
4702
  errorMessage: "Failed to promote rollout",
4542
- successMessage: "Rollout promoted",
4703
+ successMessage: "Promote sent",
4543
4704
  },
4544
4705
  "promote-full": {
4545
4706
  errorMessage: "Failed to promote rollout",
4546
- successMessage: "Rollout promoted to full — remaining steps skipped",
4707
+ successMessage: "Promote full sent",
4547
4708
  },
4548
4709
  "skip-step": {
4549
4710
  errorMessage: "Failed to skip step",
4550
- successMessage: "Skipped to the next step",
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
- throw new Error(error.error || `HTTP ${response.status}`);
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
- meta: { errorMessage: "Rollout action failed" },
4580
- onSuccess: (_, variables) => {
4581
- showApiSuccess(ROLLOUT_ACTION_MESSAGES[variables.action].successMessage);
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',