@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
@@ -0,0 +1,145 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { VersionInfo } from '../../api/client'
4
+ import { RadarVersionLine } from './RadarVersionLine'
5
+
6
+ const version: VersionInfo = {
7
+ currentVersion: '1.2.3',
8
+ latestVersion: '1.3.0',
9
+ updateAvailable: true,
10
+ installMethod: 'direct',
11
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.3.0',
12
+ }
13
+
14
+ describe('RadarVersionLine', () => {
15
+ it('shows the running version without an upgrade affordance when up to date', () => {
16
+ const html = renderToString(
17
+ <RadarVersionLine version={{ ...version, latestVersion: '1.2.3', updateAvailable: false }} />,
18
+ )
19
+ expect(html).toContain('Radar')
20
+ expect(html).toContain('v1.2.3')
21
+ expect(html).not.toContain('available')
22
+ })
23
+
24
+ it('shows patch upgrades with the quiet treatment', () => {
25
+ const html = renderToString(
26
+ <RadarVersionLine version={{ ...version, latestVersion: '1.2.4' }} />,
27
+ )
28
+ expect(html).toContain('v1.2.3')
29
+ expect(html).toContain('v1.2.4')
30
+ expect(html).toContain('available')
31
+ expect(html).toContain('text-accent-text hover:text-accent')
32
+ })
33
+
34
+ it('makes minor upgrades more prominent', () => {
35
+ const html = renderToString(<RadarVersionLine version={version} />)
36
+ expect(html).toContain('font-medium text-accent hover:text-accent-light')
37
+ expect(html).not.toContain('minor releases behind')
38
+ })
39
+
40
+ it('uses warning emphasis and explains when an installation is three minor releases behind', () => {
41
+ const html = renderToString(
42
+ <RadarVersionLine version={{ ...version, currentVersion: '1.0.9', latestVersion: '1.3.0' }} />,
43
+ )
44
+ expect(html).toContain('font-medium text-warning-text hover:opacity-80')
45
+ expect(html).toContain('This installation is 3 minor releases behind')
46
+ })
47
+
48
+ it('uses warning emphasis for a major upgrade', () => {
49
+ const html = renderToString(
50
+ <RadarVersionLine version={{ ...version, currentVersion: '0.12.0', latestVersion: '1.3.0' }} />,
51
+ )
52
+ expect(html).toContain('font-medium text-warning-text hover:opacity-80')
53
+ expect(html).toContain('A major Radar upgrade is available')
54
+ })
55
+
56
+ it('does not claim manager discovery has failed while it is loading', () => {
57
+ const html = renderToString(<RadarVersionLine version={version} managerLoading />)
58
+ expect(html).toContain('v1.3.0')
59
+ expect(html).toContain('available')
60
+ expect(html).toContain('lucide-circle-arrow-up')
61
+ expect(html).toContain('Checking how this installation is managed')
62
+ expect(html).not.toContain('could not be confirmed')
63
+ expect(html).not.toContain('<a')
64
+ expect(html).not.toContain('<button')
65
+ expect(html).toContain('class="sr-only"')
66
+ expect(html).not.toContain('aria-label=')
67
+ })
68
+
69
+ it('opens actionable upgrade instructions when the installation manager is unknown', () => {
70
+ const html = renderToString(<RadarVersionLine version={version} />)
71
+ expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
72
+ expect(html).not.toContain('#upgrading')
73
+ expect(html).toContain('Open the in-cluster upgrade instructions')
74
+ expect(html).not.toContain(version.releaseUrl)
75
+ expect(html).toContain('font-medium text-accent hover:text-accent-light')
76
+ })
77
+
78
+ it('keeps the visible upgrade label in the accessible name', () => {
79
+ const html = renderToString(<RadarVersionLine version={version} />)
80
+ expect(html).toContain('aria-label="v1.3.0 available —')
81
+ })
82
+
83
+ it('deep-links exact Helm ownership when the host supports it', () => {
84
+ const html = renderToString(
85
+ <RadarVersionLine
86
+ version={version}
87
+ manager={{ ownership: 'helm', namespace: 'radar-system', release: 'radar' }}
88
+ onNavigateToHelmRelease={() => {}}
89
+ />,
90
+ )
91
+ expect(html).toContain('Managed by Helm release radar-system/radar')
92
+ expect(html).toContain('Open the release to upgrade')
93
+ })
94
+
95
+ it('describes the docs fallback when a Helm navigation callback is unavailable', () => {
96
+ const html = renderToString(
97
+ <RadarVersionLine
98
+ version={version}
99
+ manager={{ ownership: 'helm', namespace: 'radar-system', release: 'radar' }}
100
+ />,
101
+ )
102
+ expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
103
+ expect(html).toContain('Managed by Helm release radar-system/radar')
104
+ expect(html).toContain('Open the in-cluster upgrade instructions')
105
+ expect(html).not.toContain('Open the release to upgrade')
106
+ })
107
+
108
+ it('deep-links verified GitOps ownership', () => {
109
+ const controllerRef = { group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', namespace: 'flux-system', name: 'radar' }
110
+ const verified = renderToString(
111
+ <RadarVersionLine
112
+ version={version}
113
+ manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar', controllerRef }}
114
+ onNavigateToGitOps={() => {}}
115
+ />,
116
+ )
117
+ expect(verified).toContain('Managed by Kustomization flux-system/radar')
118
+ expect(verified).toContain('Open it to upgrade through GitOps')
119
+
120
+ const suspected = renderToString(
121
+ <RadarVersionLine
122
+ version={version}
123
+ manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar' }}
124
+ onNavigateToGitOps={() => {}}
125
+ />,
126
+ )
127
+ expect(suspected).toContain('appears to be managed through GitOps (Kustomization flux-system/radar)')
128
+ expect(suspected).toContain('Open the upgrade instructions')
129
+ expect(suspected).not.toContain('Managed by Kustomization')
130
+ })
131
+
132
+ it('describes the docs fallback when a GitOps navigation callback is unavailable', () => {
133
+ const controllerRef = { group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', namespace: 'flux-system', name: 'radar' }
134
+ const html = renderToString(
135
+ <RadarVersionLine
136
+ version={version}
137
+ manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar', controllerRef }}
138
+ />,
139
+ )
140
+ expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
141
+ expect(html).toContain('Managed by Kustomization flux-system/radar')
142
+ expect(html).toContain('Open the in-cluster upgrade instructions and apply the change through GitOps')
143
+ expect(html).not.toContain('Open it to upgrade through GitOps')
144
+ })
145
+ })
@@ -0,0 +1,137 @@
1
+ import { ArrowUpCircle } from 'lucide-react'
2
+ import { gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
3
+ import type { CloudConnectSelf, VersionInfo } from '../../api/client'
4
+ import {
5
+ getVersionUpdateStatus,
6
+ IN_CLUSTER_UPGRADE_URL,
7
+ type VersionUpdateTier,
8
+ } from '../../utils/version'
9
+ import { Tooltip } from '../ui/Tooltip'
10
+
11
+ interface RadarVersionLineProps {
12
+ version: VersionInfo
13
+ manager?: CloudConnectSelf
14
+ managerLoading?: boolean
15
+ onNavigateToHelmRelease?: (namespace: string, release: string) => void
16
+ onNavigateToGitOps?: (path: string) => void
17
+ }
18
+
19
+ function displayVersion(version: string): string {
20
+ return version === 'dev' || version.startsWith('v') ? version : `v${version}`
21
+ }
22
+
23
+ export function RadarVersionLine({
24
+ version,
25
+ manager,
26
+ managerLoading = false,
27
+ onNavigateToHelmRelease,
28
+ onNavigateToGitOps,
29
+ }: RadarVersionLineProps) {
30
+ const latestVersion = version.latestVersion
31
+ const updateStatus = getVersionUpdateStatus(version.currentVersion, latestVersion)
32
+ const showUpgrade = version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
33
+
34
+ if (!showUpgrade) {
35
+ return <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
36
+ }
37
+
38
+ const controller = manager?.controllerRef
39
+ const gitOpsPath = controller
40
+ ? gitOpsRouteForResource({
41
+ apiVersion: controller.group ? `${controller.group}/v1` : undefined,
42
+ kind: controller.kind,
43
+ metadata: { namespace: controller.namespace, name: controller.name },
44
+ })
45
+ : null
46
+
47
+ let detail = managerLoading
48
+ ? 'Checking how this installation is managed.'
49
+ : 'The installation manager could not be confirmed. Open the in-cluster upgrade instructions.'
50
+ let onClick: (() => void) | undefined
51
+ const actionClassName = `inline-flex items-center gap-1 transition-colors ${upgradeActionClassName(updateStatus.tier)}`
52
+
53
+ if (manager?.ownership === 'helm' && manager.namespace && manager.release) {
54
+ if (onNavigateToHelmRelease) {
55
+ detail = `Managed by Helm release ${manager.namespace}/${manager.release}. Open the release to upgrade.`
56
+ onClick = () => onNavigateToHelmRelease(manager.namespace!, manager.release!)
57
+ } else {
58
+ detail = `Managed by Helm release ${manager.namespace}/${manager.release}. Open the in-cluster upgrade instructions.`
59
+ }
60
+ } else if (controller && gitOpsPath) {
61
+ const objectName = `${controller.namespace ? `${controller.namespace}/` : ''}${controller.name}`
62
+ if (onNavigateToGitOps) {
63
+ detail = `Managed by ${controller.kind} ${objectName}. Open it to upgrade through GitOps.`
64
+ onClick = () => onNavigateToGitOps(gitOpsPath)
65
+ } else {
66
+ detail = `Managed by ${controller.kind} ${objectName}. Open the in-cluster upgrade instructions and apply the change through GitOps.`
67
+ }
68
+ } else if (manager?.ownership === 'gitops') {
69
+ detail = manager.controller
70
+ ? `This installation appears to be managed through GitOps (${manager.controller}). Open the upgrade instructions and apply the change through its source of truth.`
71
+ : 'This installation appears to be managed through GitOps. Open the upgrade instructions and apply the change through its source of truth.'
72
+ }
73
+
74
+ const ageDetail = updateAgeDetail(updateStatus)
75
+ const accessibleLabel = `${displayVersion(latestVersion)} available${ageDetail ? `. ${ageDetail}` : ''} — ${detail}`
76
+ const action = managerLoading ? (
77
+ <span className={actionClassName}>
78
+ <UpgradeLabel version={latestVersion} />
79
+ <span className="sr-only">{detail}</span>
80
+ </span>
81
+ ) : onClick ? (
82
+ <button
83
+ type="button"
84
+ className={actionClassName}
85
+ onClick={onClick}
86
+ aria-label={accessibleLabel}
87
+ >
88
+ <UpgradeLabel version={latestVersion} />
89
+ </button>
90
+ ) : (
91
+ <a
92
+ href={IN_CLUSTER_UPGRADE_URL}
93
+ target="_blank"
94
+ rel="noreferrer"
95
+ className={actionClassName}
96
+ aria-label={accessibleLabel}
97
+ >
98
+ <UpgradeLabel version={latestVersion} />
99
+ </a>
100
+ )
101
+
102
+ return (
103
+ <span className="inline-flex flex-wrap items-center gap-x-1">
104
+ <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
105
+ <span className="inline-flex items-center gap-1">
106
+ <span aria-hidden>·</span>
107
+ <Tooltip
108
+ content={accessibleLabel}
109
+ className="!whitespace-normal !max-w-sm"
110
+ >
111
+ {action}
112
+ </Tooltip>
113
+ </span>
114
+ </span>
115
+ )
116
+ }
117
+
118
+ function upgradeActionClassName(tier: VersionUpdateTier): string {
119
+ if (tier === 'patch') return 'text-accent-text hover:text-accent'
120
+ if (tier === 'minor') return 'font-medium text-accent hover:text-accent-light'
121
+ return 'font-medium text-warning-text hover:opacity-80'
122
+ }
123
+
124
+ function updateAgeDetail(status: ReturnType<typeof getVersionUpdateStatus>): string | undefined {
125
+ if (status.majorVersionBehind) return 'A major Radar upgrade is available.'
126
+ if (status.tier !== 'stale' || !status.minorVersionsBehind) return undefined
127
+ return `This installation is ${status.minorVersionsBehind} minor releases behind.`
128
+ }
129
+
130
+ function UpgradeLabel({ version }: { version: string }) {
131
+ return (
132
+ <>
133
+ <ArrowUpCircle className="h-3.5 w-3.5 shrink-0" aria-hidden />
134
+ <span><span className="font-mono">{displayVersion(version)}</span> available</span>
135
+ </>
136
+ )
137
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
1
+ import { createElement, useState, useRef, useEffect, useMemo, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { X, File, Link2, ChevronRight, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
4
4
  import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
@@ -7,6 +7,9 @@ import type { FileNode } from '../../types'
7
7
  import { formatBytes } from '../../utils/format'
8
8
  import { downloadBlob, filterTree } from './file-browser-utils'
9
9
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
10
+ import { isDesktopApp } from '../../utils/desktop-download'
11
+ import { openFile, openFolder } from '../../utils/desktop-open-folder'
12
+ import { useToast } from '../ui/Toast'
10
13
  import { Tooltip } from '../ui/Tooltip'
11
14
 
12
15
  interface PodFilesystem {
@@ -35,6 +38,36 @@ async function fetchPodFiles(
35
38
  return response.json()
36
39
  }
37
40
 
41
+ /**
42
+ * Desktop only: has the backend write the pod file straight to disk. The browser
43
+ * route would hand the whole file to the webview only to have it hand every byte
44
+ * back to be saved, which is what puts a large file out of reach there.
45
+ * Returns the path it was saved to.
46
+ */
47
+ async function savePodFileToDisk(
48
+ namespace: string,
49
+ podName: string,
50
+ container: string,
51
+ filePath: string,
52
+ ): Promise<string> {
53
+ const params = new URLSearchParams()
54
+ params.set('container', container)
55
+ params.set('path', filePath)
56
+
57
+ const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/files/save?${params.toString()}`), {
58
+ method: 'POST',
59
+ credentials: getCredentialsMode(),
60
+ headers: getAuthHeaders(),
61
+ })
62
+ if (response.status === 204) throw new Error('cancelled')
63
+ if (!response.ok) {
64
+ const error = await response.json().catch(() => ({ error: 'Save failed' }))
65
+ throw new Error(error.error || `HTTP ${response.status}`)
66
+ }
67
+ const body = await response.json()
68
+ return body.path
69
+ }
70
+
38
71
  interface PodFilesystemModalProps {
39
72
  open: boolean
40
73
  onClose: () => void
@@ -309,6 +342,7 @@ interface PodFileTreeNodeProps {
309
342
 
310
343
  function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: PodFileTreeNodeProps) {
311
344
  const [downloading, setDownloading] = useState(false)
345
+ const { showSuccess, showError } = useToast()
312
346
  const isDir = node.type === 'dir'
313
347
  const isSymlink = node.type === 'symlink'
314
348
  const isDownloadable = !isDir // files and symlinks can be downloaded
@@ -319,6 +353,21 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
319
353
 
320
354
  setDownloading(true)
321
355
  try {
356
+ if (await isDesktopApp()) {
357
+ const savedPath = await savePodFileToDisk(namespace, podName, container, node.path)
358
+ showSuccess(
359
+ 'File saved',
360
+ savedPath,
361
+ {
362
+ label: 'Show in Finder',
363
+ icon: createElement(FolderOpen, { className: 'w-3.5 h-3.5' }),
364
+ onClick: () => openFolder(savedPath),
365
+ },
366
+ () => openFile(savedPath),
367
+ )
368
+ return
369
+ }
370
+
322
371
  const params = new URLSearchParams()
323
372
  params.set('container', container)
324
373
  params.set('path', node.path)
@@ -335,7 +384,10 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
335
384
  const blob = await response.blob()
336
385
  await downloadBlob(blob, node.name)
337
386
  } catch (err) {
338
- console.error('Download failed:', err)
387
+ const message = err instanceof Error ? err.message : String(err)
388
+ if (message !== 'cancelled') {
389
+ showError(`Could not download ${node.name}`, message)
390
+ }
339
391
  } finally {
340
392
  setDownloading(false)
341
393
  }
@@ -16,10 +16,12 @@ import {
16
16
  ResourcesView as BaseResourcesView,
17
17
  CORE_RESOURCES,
18
18
  intersectWorkloadWrites,
19
+ hasCuratedColumns,
20
+ sanitizePrinterTable,
19
21
  } from '@skyhook-io/k8s-ui'
20
- import type { Capabilities, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
22
+ import type { Capabilities, PrinterTable, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
21
23
  import type { SelectedResource } from '../../types'
22
- import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
24
+ import { apiVersionToGroup, kindToPluralWithGroup, type NavigateToResource } from '../../utils/navigation'
23
25
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
24
26
  import { getSkeletonYaml } from '../../utils/skeleton-yaml'
25
27
 
@@ -229,12 +231,19 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
229
231
  // Fetch full data only for the selected kind
230
232
  const selectedKindQuery = useQuery({
231
233
  queryKey: ['resources', selectedKind?.name, isSelectedCrd ? selectedKind?.group : '', namespaces],
232
- queryFn: async () => {
233
- if (!selectedKind) return []
234
+ queryFn: async (): Promise<{ items: any[]; printerTable: PrinterTable | null }> => {
235
+ if (!selectedKind) return { items: [], printerTable: null }
234
236
  const params = new URLSearchParams()
235
237
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
236
238
  if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
237
239
  if (selectedKindSummaryServed) params.set('include', 'summary')
240
+ // Only CRDs can declare printer columns, and a curated kind discards the
241
+ // result — so table mode is requested from exactly the kinds that can use
242
+ // it. Resolving a table costs the server a CRD read per request; doing
243
+ // that for a kind whose columns are hand-curated is pure waste.
244
+ const wantsTable = isSelectedCrd && !!selectedKind.group &&
245
+ !hasCuratedColumns(selectedKind.name, selectedKind.group)
246
+ if (wantsTable) params.set('table', '1')
238
247
  const startedAt = performance.now()
239
248
  debugNamespaceLog('resources:selected-kind-fetch-start', {
240
249
  kind: selectedKind.name,
@@ -258,7 +267,19 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
258
267
  const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
259
268
  throw new ApiError(errorData.error || `Failed to fetch ${selectedKind.name}`, res.status, errorData)
260
269
  }
261
- return res.json()
270
+ const body = await res.json()
271
+ // Both branches are current shapes, not a guess at a legacy one: a Radar
272
+ // backend that predates `table` ignores the parameter and answers with
273
+ // the bare array. @skyhook-io/radar-app is versioned independently of the
274
+ // backend it points at, so a consumer can pair a new frontend with an
275
+ // older Radar — and reading that array as a missing envelope would render
276
+ // every CRD list empty. Items and columns still come from one response,
277
+ // so a row can never render against another fetch's cells.
278
+ if (!wantsTable || Array.isArray(body)) return { items: body as any[], printerTable: null }
279
+ return {
280
+ items: Array.isArray(body?.items) ? body.items as any[] : [],
281
+ printerTable: sanitizePrinterTable(body),
282
+ }
262
283
  },
263
284
  enabled: !!selectedKind && !selectedKindQueryBlocked,
264
285
  staleTime: 30000,
@@ -275,13 +296,13 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
275
296
  return {
276
297
  resourceName: selectedKind.name,
277
298
  group: selectedKind.group,
278
- data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
299
+ data: selectedKindQueryBlocked ? [] : selectedKindQuery.data?.items,
279
300
  isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
280
301
  error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
281
302
  refetch: selectedKindQuery.refetch,
282
303
  dataUpdatedAt: selectedKindQuery.dataUpdatedAt,
283
304
  }
284
- }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
305
+ }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data?.items, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
285
306
 
286
307
  // Metrics
287
308
  const { data: topPodMetrics } = useTopPodMetrics({ enabled: topPodMetricsEnabled, namespaces })
@@ -352,6 +373,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
352
373
  resourceReasons={countsData?.reasons}
353
374
  resourceUnavailable={countsData?.unavailable}
354
375
  selectedKindQuery={selectedKindQueryResult}
376
+ printerTable={selectedKindQueryBlocked ? null : selectedKindQuery.data?.printerTable ?? null}
355
377
  connectionState={connection.state}
356
378
  largeListGuard={largeListGuard}
357
379
  onSelectedKindChange={setSelectedKind}
@@ -394,7 +416,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
394
416
  initialYaml={createDialogYaml}
395
417
  title={createDialogTitle}
396
418
  onCreated={(result) => {
397
- onResourceClick?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
419
+ const group = apiVersionToGroup(result.apiVersion)
420
+ onResourceClick?.({ kind: kindToPluralWithGroup(result.kind, group), namespace: result.namespace, name: result.name, group })
398
421
  }}
399
422
  />
400
423
  </>
@@ -4,8 +4,8 @@ import { useScaleWorkload, fetchJSON } from '../../../api/client'
4
4
  import { useRBACSubject } from '../../../api/rbac'
5
5
  import { usePolicyResource } from '../../../api/policy'
6
6
  import { useQueries, useQueryClient } from '@tanstack/react-query'
7
- import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
8
- import type { Relationships, ResourceRef, ResourceWithRelationships } from '../../../types'
7
+ import { kindToPlural, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
8
+ import type { Relationships, ResourceRef, ResourceWithRelationships, WorkloadPodInfo } from '../../../types'
9
9
  import type { ScalerDiagnosis } from '@skyhook-io/k8s-ui/components/resources/renderers/WorkloadRenderer'
10
10
 
11
11
  // Map plural lowercase kind to singular PascalCase for ownerReferences matching
@@ -26,9 +26,10 @@ interface WorkloadRendererProps {
26
26
  onNavigate?: (ref: ResourceRef) => void
27
27
  relationships?: Relationships
28
28
  scaleBlockedBy?: ResourceRef[]
29
+ workloadPods?: WorkloadPodInfo[]
29
30
  }
30
31
 
31
- export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: WorkloadRendererProps) {
32
+ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy, workloadPods }: WorkloadRendererProps) {
32
33
  const navigate = useNavigate()
33
34
  const queryClient = useQueryClient()
34
35
  const scaleMutation = useScaleWorkload()
@@ -54,13 +55,19 @@ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: Wor
54
55
  })
55
56
  const hpaQueries = useQueries({
56
57
  queries: hpaRefs.map(ref => ({
57
- queryKey: ['resource', kindToPlural(ref.kind), ref.namespace, ref.name, ref.group],
58
+ queryKey: [
59
+ 'resource',
60
+ kindToPluralWithGroup(ref.kind, ref.group ?? ''),
61
+ ref.namespace,
62
+ ref.name,
63
+ ref.group,
64
+ ],
58
65
  queryFn: () => {
59
66
  const ns = ref.namespace || '_'
60
67
  const params = new URLSearchParams()
61
68
  if (ref.group) params.set('group', ref.group)
62
69
  const query = params.toString()
63
- return fetchJSON<ResourceWithRelationships<any>>(`/resources/${kindToPlural(ref.kind)}/${ns}/${ref.name}${query ? `?${query}` : ''}`)
70
+ return fetchJSON<ResourceWithRelationships<any>>(`/resources/${kindToPluralWithGroup(ref.kind, ref.group ?? '')}/${ns}/${ref.name}${query ? `?${query}` : ''}`)
64
71
  },
65
72
  enabled: Boolean(ref.kind && ref.name),
66
73
  staleTime: 10000,
@@ -90,6 +97,7 @@ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: Wor
90
97
  policyLoading={policyLoading}
91
98
  policyError={policyError as Error | null}
92
99
  scaleBlockedBy={scaleBlockedBy}
100
+ workloadPods={workloadPods}
93
101
  scalerDiagnostics={scalerDiagnostics}
94
102
  onScale={async (replicas) => {
95
103
  await scaleMutation.mutateAsync({
@@ -12,7 +12,7 @@ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
12
12
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
13
13
  import { apiUrl, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config'
14
14
  import {
15
- useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
15
+ useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus, useCapabilities,
16
16
  } from '../../api/client'
17
17
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
18
18
  import { Input, SelectMenu } from '@skyhook-io/k8s-ui'
@@ -21,6 +21,7 @@ import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
21
21
  import { MyPermissionsContent } from './MyPermissionsDialog'
22
22
  import { useDiagnose } from '../diagnose/DiagnoseContext'
23
23
  import { currencyOptionsForValue } from './currency-options'
24
+ import { versionUpdateURL } from '../../utils/version'
24
25
 
25
26
  // The loopback URL an MCP client is told to connect to. Shared by the overview
26
27
  // row and the MCP section: both must carry the base path, or the URL they
@@ -45,6 +46,7 @@ interface Config {
45
46
  argoCdUrl?: string
46
47
  argoCdInsecureTls?: boolean
47
48
  mcp?: boolean | null
49
+ restoreLastDesktopContext?: boolean | null
48
50
  }
49
51
 
50
52
  interface ConfigResponse {
@@ -102,6 +104,7 @@ function normalizeStartup(c: Config) {
102
104
  historyLimit: c.historyLimit ?? null,
103
105
  mcp: c.mcp ?? true,
104
106
  opencostCurrency: c.opencostCurrency?.trim().toUpperCase() ?? '',
107
+ restoreLastDesktopContext: c.restoreLastDesktopContext ?? true,
105
108
  }
106
109
  }
107
110
 
@@ -159,7 +162,8 @@ export function SettingsDialog({
159
162
  const clusterDirty =
160
163
  edN.kubeconfig !== svN.kubeconfig ||
161
164
  edN.kubeconfigDirs !== svN.kubeconfigDirs ||
162
- edN.namespace !== svN.namespace
165
+ edN.namespace !== svN.namespace ||
166
+ edN.restoreLastDesktopContext !== svN.restoreLastDesktopContext
163
167
  const serverDirty =
164
168
  edN.port !== svN.port || edN.noBrowser !== svN.noBrowser || edN.browser !== svN.browser
165
169
  const mcpDirty = edN.mcp !== svN.mcp
@@ -504,6 +508,7 @@ export function SettingsDialog({
504
508
  <ClusterSection
505
509
  config={editedConfig}
506
510
  effectiveConfig={configData?.effective}
511
+ isDesktop={isDesktop}
507
512
  onChange={updateConfigField}
508
513
  />
509
514
  </div>
@@ -907,6 +912,8 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
907
912
  const { data: cluster } = useClusterInfo()
908
913
  const { data: prom } = usePrometheusStatus()
909
914
  const { data: argo } = useArgoStatus(active)
915
+ const { data: capabilitiesData } = useCapabilities()
916
+ const deploymentMode = capabilitiesData ? (capabilitiesData.deployment?.mode ?? 'local') : undefined
910
917
  const { data: version } = useVersionCheck()
911
918
  const capabilities = useCapabilitiesContext()
912
919
  const diag = useDiagnose()
@@ -963,9 +970,9 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
963
970
 
964
971
  return (
965
972
  <div className="space-y-4">
966
- {version?.updateAvailable && (
973
+ {version?.updateAvailable && deploymentMode !== undefined && deploymentMode !== 'cloud' && (
967
974
  <a
968
- href={version.releaseUrl}
975
+ href={versionUpdateURL(deploymentMode, version.releaseUrl)}
969
976
  target="_blank"
970
977
  rel="noreferrer"
971
978
  className="flex items-center gap-2 px-3 py-2 text-xs rounded-md border border-skyhook-500/30 bg-skyhook-500/10 hover:bg-skyhook-500/15 transition-colors"
@@ -1052,10 +1059,12 @@ function AIUnavailableNotice() {
1052
1059
  function ClusterSection({
1053
1060
  config,
1054
1061
  effectiveConfig,
1062
+ isDesktop,
1055
1063
  onChange,
1056
1064
  }: {
1057
1065
  config: Config
1058
1066
  effectiveConfig?: Config
1067
+ isDesktop: boolean
1059
1068
  onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
1060
1069
  }) {
1061
1070
  const kubeconfigDirs = effectiveConfig ? (effectiveConfig.kubeconfigDirs ?? []) : config.kubeconfigDirs
@@ -1087,6 +1096,14 @@ function ClusterSection({
1087
1096
  placeholder="All namespaces"
1088
1097
  onChange={(v) => onChange('namespace', v || undefined)}
1089
1098
  />
1099
+ {isDesktop && (
1100
+ <ConfigToggle
1101
+ label="Reopen on the last used cluster"
1102
+ description="Come back to the cluster you were working in. Turn off to use your kubeconfig's current context on the next Desktop start."
1103
+ value={config.restoreLastDesktopContext ?? true}
1104
+ onChange={(v) => onChange('restoreLastDesktopContext', v ? undefined : false)}
1105
+ />
1106
+ )}
1090
1107
  </>
1091
1108
  )
1092
1109
  }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { ErrorBoundary } from './ErrorBoundary'
4
+
5
+ // Navigating re-renders this boundary rather than remounting it, so a boundary
6
+ // that only latched would keep the fallback on screen for the rest of the
7
+ // session. These drive the lifecycle hooks directly - a caught error needs a
8
+ // real render loop, which the SSR-string tests used elsewhere here cannot give.
9
+ const caught = (resetKey: string) => ({
10
+ hasError: true,
11
+ error: new Error("Cannot read properties of undefined (reading 'nodeCount')"),
12
+ resetKey,
13
+ })
14
+
15
+ describe('ErrorBoundary reset', () => {
16
+ it('clears a caught error when resetKey changes', () => {
17
+ expect(ErrorBoundary.getDerivedStateFromProps({ children: null, resetKey: '/topology' }, caught('/'))).toEqual({
18
+ hasError: false,
19
+ error: null,
20
+ resetKey: '/topology',
21
+ })
22
+ })
23
+
24
+ it('clears it when only a later path segment changes', () => {
25
+ // The view is just the first path segment, so /resources/pods and
26
+ // /resources/services are the same view. Resetting per view would leave a
27
+ // crash under /resources trapping the user as they switch kinds.
28
+ const next = ErrorBoundary.getDerivedStateFromProps(
29
+ { children: null, resetKey: '/resources/services' },
30
+ caught('/resources/pods'),
31
+ )
32
+ expect(next?.hasError).toBe(false)
33
+ })
34
+
35
+ it('clears it when only the query changes', () => {
36
+ // Selection rides in the query, so a path-only key would strand a crash on
37
+ // the view that produced it.
38
+ const next = ErrorBoundary.getDerivedStateFromProps(
39
+ { children: null, resetKey: '/resources/pods?resource=kube-system/coredns' },
40
+ caught('/resources/pods'),
41
+ )
42
+ expect(next?.hasError).toBe(false)
43
+ })
44
+
45
+ it('holds the error while resetKey is unchanged', () => {
46
+ expect(ErrorBoundary.getDerivedStateFromProps({ children: null, resetKey: '/' }, caught('/'))).toBeNull()
47
+ })
48
+
49
+ it('records the error', () => {
50
+ expect(ErrorBoundary.getDerivedStateFromError(new Error('boom'))).toEqual({
51
+ hasError: true,
52
+ error: new Error('boom'),
53
+ })
54
+ })
55
+ })