@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
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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/${
|
|
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
|
+
})
|