@skyhook-io/radar-app 1.11.0 → 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 +7 -7
- package/src/App.tsx +37 -17
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +244 -39
- package/src/api/client.yaml.test.ts +3 -3
- package/src/api/version-check.test.ts +78 -0
- package/src/components/CloudConnectFlow.tsx +46 -26
- package/src/components/CloudFunnelButton.tsx +166 -129
- package/src/components/ConnectionErrorView.test.tsx +74 -1
- package/src/components/ConnectionErrorView.tsx +22 -20
- package/src/components/ContextSwitcher.tsx +10 -13
- 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 +22 -12
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- 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 +63 -1
- package/src/components/home/CostCard.tsx +12 -7
- 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/home/mcpToolCatalog.ts +12 -0
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +54 -2
- package/src/components/resources/ResourcesView.tsx +31 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +181 -40
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
- 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/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.test.ts +60 -0
- package/src/components/workload/WorkloadView.tsx +272 -30
- package/src/contexts/CapabilitiesContext.test.tsx +29 -0
- package/src/contexts/CapabilitiesContext.tsx +7 -3
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/navigation.test.ts +45 -0
- package/src/utils/navigation.ts +5 -5
- package/src/utils/topology-selection.ts +3 -2
- package/src/utils/version.test.ts +37 -0
- package/src/utils/version.ts +56 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { CURRENCY_OPTIONS, currencyOptionsForValue } from './currency-options'
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks()
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
describe('currency options', () => {
|
|
9
|
+
it('offers Auto followed by named ISO 4217 currencies', () => {
|
|
10
|
+
expect(CURRENCY_OPTIONS[0]).toEqual({
|
|
11
|
+
value: '',
|
|
12
|
+
label: 'Auto (detect from OpenCost/Kubecost)',
|
|
13
|
+
})
|
|
14
|
+
expect(CURRENCY_OPTIONS).toContainEqual({
|
|
15
|
+
value: 'EUR',
|
|
16
|
+
label: 'Euro (EUR)',
|
|
17
|
+
})
|
|
18
|
+
expect(CURRENCY_OPTIONS).toContainEqual({
|
|
19
|
+
value: 'USD',
|
|
20
|
+
label: 'US Dollar (USD)',
|
|
21
|
+
})
|
|
22
|
+
for (const code of ['MRU', 'SLE', 'UYW', 'VED', 'VES', 'XAD', 'XCG', 'ZWG']) {
|
|
23
|
+
expect(CURRENCY_OPTIONS.some((option) => option.value === code)).toBe(true)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('contains unique uppercase three-letter currency codes', () => {
|
|
28
|
+
const codes = CURRENCY_OPTIONS.slice(1).map((option) => option.value)
|
|
29
|
+
expect(new Set(codes).size).toBe(codes.length)
|
|
30
|
+
expect(codes.every((code) => /^[A-Z]{3}$/.test(code))).toBe(true)
|
|
31
|
+
expect(codes).not.toContain('XTS')
|
|
32
|
+
expect(codes).not.toContain('XXX')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('supplements current codes when browser currency data lags', async () => {
|
|
36
|
+
vi.spyOn(Intl, 'supportedValuesOf').mockReturnValue(['EUR', 'USD'])
|
|
37
|
+
vi.resetModules()
|
|
38
|
+
const { CURRENCY_OPTIONS: options } = await import('./currency-options')
|
|
39
|
+
|
|
40
|
+
for (const code of ['MRU', 'SLE', 'UYW', 'VED', 'VES', 'XAD', 'XCG', 'ZWG']) {
|
|
41
|
+
expect(options.some((option) => option.value === code)).toBe(true)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('shows an existing saved code instead of misrepresenting it as Auto', () => {
|
|
46
|
+
expect(currencyOptionsForValue('EUR')).toBe(CURRENCY_OPTIONS)
|
|
47
|
+
expect(currencyOptionsForValue('ADP')).toContainEqual({ value: 'ADP', label: 'ADP' })
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SelectMenuOption } from '@skyhook-io/k8s-ui'
|
|
2
|
+
|
|
3
|
+
const currencyNames = new Intl.DisplayNames(['en'], { type: 'currency' })
|
|
4
|
+
const currentISO4217CurrenciesAddedSinceCLDR32: Record<string, string> = {
|
|
5
|
+
MRU: 'Mauritanian Ouguiya',
|
|
6
|
+
SLE: 'Sierra Leonean Leone',
|
|
7
|
+
UYW: 'Uruguayan Nominal Wage Index Unit',
|
|
8
|
+
VED: 'Bolívar Soberano',
|
|
9
|
+
VES: 'Venezuelan Bolívar',
|
|
10
|
+
XAD: 'Arab Accounting Dinar',
|
|
11
|
+
XCG: 'Caribbean Guilder',
|
|
12
|
+
ZWG: 'Zimbabwean Gold',
|
|
13
|
+
}
|
|
14
|
+
const currencyCodes = [
|
|
15
|
+
...new Set([
|
|
16
|
+
...Intl.supportedValuesOf('currency'),
|
|
17
|
+
...Object.keys(currentISO4217CurrenciesAddedSinceCLDR32),
|
|
18
|
+
]),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export const CURRENCY_OPTIONS: SelectMenuOption[] = [
|
|
22
|
+
{ value: '', label: 'Auto (detect from OpenCost/Kubecost)' },
|
|
23
|
+
...currencyCodes
|
|
24
|
+
.filter((code) => code !== 'XTS' && code !== 'XXX')
|
|
25
|
+
.map((code) => {
|
|
26
|
+
const name = currentISO4217CurrenciesAddedSinceCLDR32[code] ?? currencyNames.of(code)
|
|
27
|
+
return {
|
|
28
|
+
value: code,
|
|
29
|
+
label: name && name !== code ? `${name} (${code})` : code,
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
.sort((a, b) => a.label.localeCompare(b.label, 'en')),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
export function currencyOptionsForValue(value: string): SelectMenuOption[] {
|
|
36
|
+
if (!value || CURRENCY_OPTIONS.some((option) => option.value === value)) return CURRENCY_OPTIONS
|
|
37
|
+
return [...CURRENCY_OPTIONS, { value, label: value }]
|
|
38
|
+
}
|
|
@@ -73,3 +73,43 @@ describe('formatForGitHub desktop section', () => {
|
|
|
73
73
|
expect(md).toContain('Desktop: `(unset)`')
|
|
74
74
|
})
|
|
75
75
|
})
|
|
76
|
+
|
|
77
|
+
describe('formatForGitHub kubeconfig section', () => {
|
|
78
|
+
it('reports combined source counts and ignored ambient configuration', () => {
|
|
79
|
+
const md = formatForGitHub({
|
|
80
|
+
...baseSnapshot,
|
|
81
|
+
kubeconfig: {
|
|
82
|
+
mode: 'multi-source',
|
|
83
|
+
fileCount: 3,
|
|
84
|
+
directoryFileCount: 2,
|
|
85
|
+
contextCount: 7,
|
|
86
|
+
enrichedFromShell: true,
|
|
87
|
+
kubeconfigEnvIgnored: true,
|
|
88
|
+
kubeconfigEnvIgnoredReason: 'directories-only configuration',
|
|
89
|
+
currentContextUsesExec: false,
|
|
90
|
+
},
|
|
91
|
+
}, undefined, false)
|
|
92
|
+
|
|
93
|
+
expect(md).toContain('Mode: `multi-source` | Files: 3 | Directory Files: 2 | Contexts (after source resolution): 7')
|
|
94
|
+
expect(md).toContain('KUBECONFIG Captured From Shell: Yes | Ignored: Yes — directories-only configuration')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('omits directory counts for environment path lists', () => {
|
|
98
|
+
const md = formatForGitHub({
|
|
99
|
+
...baseSnapshot,
|
|
100
|
+
kubeconfig: {
|
|
101
|
+
mode: 'multi-env',
|
|
102
|
+
fileCount: 2,
|
|
103
|
+
directoryFileCount: 0,
|
|
104
|
+
contextCount: 4,
|
|
105
|
+
enrichedFromShell: false,
|
|
106
|
+
kubeconfigEnvIgnored: false,
|
|
107
|
+
kubeconfigEnvIgnoredReason: '',
|
|
108
|
+
currentContextUsesExec: true,
|
|
109
|
+
},
|
|
110
|
+
}, undefined, false)
|
|
111
|
+
|
|
112
|
+
expect(md).toContain('Mode: `multi-env` | Files: 2 | Contexts (after source resolution): 4')
|
|
113
|
+
expect(md).not.toContain('Directory Files:')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -171,10 +171,10 @@ function Section({ title, children, warn }: { title: string; children: React.Rea
|
|
|
171
171
|
|
|
172
172
|
function Row({ label, value, warn }: { label: string; value: React.ReactNode; warn?: boolean }) {
|
|
173
173
|
return (
|
|
174
|
-
<div className="flex items-
|
|
174
|
+
<div className="flex items-start justify-between gap-4 text-xs">
|
|
175
175
|
<span className="text-theme-text-secondary shrink-0">{label}</span>
|
|
176
176
|
<span className={clsx(
|
|
177
|
-
'text-right
|
|
177
|
+
'min-w-0 text-right break-words',
|
|
178
178
|
warn ? 'text-yellow-400' : 'text-theme-text-primary'
|
|
179
179
|
)}>{value}</span>
|
|
180
180
|
</div>
|
|
@@ -219,11 +219,19 @@ function KubeconfigSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
219
219
|
const present = k.execPluginsPresent ?? []
|
|
220
220
|
const hasMissing = missing.length > 0
|
|
221
221
|
return (
|
|
222
|
-
<Section title="Kubeconfig" warn={hasMissing}>
|
|
222
|
+
<Section title="Kubeconfig" warn={hasMissing || k.kubeconfigEnvIgnored}>
|
|
223
223
|
<Row label="Mode" value={k.mode || '(not initialized)'} />
|
|
224
224
|
<Row label="Files Loaded" value={k.fileCount} />
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
{(k.mode === 'multi-dir' || k.mode === 'multi-source') && (
|
|
226
|
+
<Row label="Directory Files Loaded" value={k.directoryFileCount} />
|
|
227
|
+
)}
|
|
228
|
+
<Row label="Contexts (after source resolution)" value={k.contextCount} />
|
|
229
|
+
<Row label="KUBECONFIG Captured From Shell" value={k.enrichedFromShell ? 'Yes' : 'No'} />
|
|
230
|
+
<Row
|
|
231
|
+
label="KUBECONFIG Ignored"
|
|
232
|
+
value={k.kubeconfigEnvIgnored ? `Yes — ${k.kubeconfigEnvIgnoredReason}` : 'No'}
|
|
233
|
+
warn={k.kubeconfigEnvIgnored}
|
|
234
|
+
/>
|
|
227
235
|
<Row
|
|
228
236
|
label="Current Context Uses Exec"
|
|
229
237
|
value={k.currentContextUsesExec ? 'Yes' : 'No'}
|
|
@@ -631,7 +639,11 @@ export function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIP
|
|
|
631
639
|
if (data.kubeconfig) {
|
|
632
640
|
const k = data.kubeconfig
|
|
633
641
|
lines.push(`### Kubeconfig`)
|
|
634
|
-
|
|
642
|
+
const directoryFiles = k.mode === 'multi-dir' || k.mode === 'multi-source'
|
|
643
|
+
? ` | Directory Files: ${k.directoryFileCount}`
|
|
644
|
+
: ''
|
|
645
|
+
lines.push(`- Mode: \`${k.mode || '(not initialized)'}\` | Files: ${k.fileCount}${directoryFiles} | Contexts (after source resolution): ${k.contextCount}`)
|
|
646
|
+
lines.push(`- KUBECONFIG Captured From Shell: ${k.enrichedFromShell ? 'Yes' : 'No'} | Ignored: ${k.kubeconfigEnvIgnored ? `Yes — ${k.kubeconfigEnvIgnoredReason}` : 'No'}`)
|
|
635
647
|
lines.push(`- Current Context Uses Exec: ${k.currentContextUsesExec ? 'Yes' : 'No'}`)
|
|
636
648
|
if (k.execPluginsPresent && k.execPluginsPresent.length > 0) {
|
|
637
649
|
lines.push(`- Exec Plugins on PATH: \`${k.execPluginsPresent.join('`, `')}\``)
|
|
@@ -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
|
+
})
|
|
@@ -3,20 +3,35 @@ import { AlertTriangle, RefreshCw } from 'lucide-react'
|
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
children: ReactNode
|
|
6
|
+
/**
|
|
7
|
+
* Clears a caught error whenever this value changes - pass whatever selects
|
|
8
|
+
* the children, typically the route. A boundary latches: nothing resets
|
|
9
|
+
* hasError on its own, so without this a view that throws keeps the fallback
|
|
10
|
+
* on screen and navigating away cannot escape it. Preferred over remounting
|
|
11
|
+
* via `key`, which would also discard the children's state on every change
|
|
12
|
+
* while nothing is wrong.
|
|
13
|
+
*/
|
|
14
|
+
resetKey: string | number
|
|
6
15
|
}
|
|
7
16
|
|
|
8
17
|
interface State {
|
|
9
18
|
hasError: boolean
|
|
10
19
|
error: Error | null
|
|
20
|
+
resetKey: string | number
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
export class ErrorBoundary extends Component<Props, State> {
|
|
14
|
-
state: State = { hasError: false, error: null }
|
|
24
|
+
state: State = { hasError: false, error: null, resetKey: this.props.resetKey }
|
|
15
25
|
|
|
16
|
-
static getDerivedStateFromError(error: Error): State {
|
|
26
|
+
static getDerivedStateFromError(error: Error): Pick<State, 'hasError' | 'error'> {
|
|
17
27
|
return { hasError: true, error }
|
|
18
28
|
}
|
|
19
29
|
|
|
30
|
+
static getDerivedStateFromProps(props: Props, state: State): State | null {
|
|
31
|
+
if (Object.is(props.resetKey, state.resetKey)) return null
|
|
32
|
+
return { hasError: false, error: null, resetKey: props.resetKey }
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
21
36
|
console.error('[ErrorBoundary]', error, info.componentStack)
|
|
22
37
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
let deploymentMode = 'local'
|
|
6
|
+
|
|
7
|
+
vi.mock('../../api/client', () => ({
|
|
8
|
+
useCapabilities: () => ({ data: { deployment: { mode: deploymentMode } } }),
|
|
9
|
+
useVersionCheck: () => ({
|
|
10
|
+
data: {
|
|
11
|
+
currentVersion: '1.2.3',
|
|
12
|
+
latestVersion: '1.3.0',
|
|
13
|
+
updateAvailable: true,
|
|
14
|
+
installMethod: 'direct',
|
|
15
|
+
releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.3.0',
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
useStartDesktopUpdate: () => ({ mutate: vi.fn(), isPending: false }),
|
|
19
|
+
useDesktopUpdateStatus: () => ({ data: undefined }),
|
|
20
|
+
useApplyDesktopUpdate: () => ({ mutate: vi.fn() }),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
import { UpdateNotification } from './UpdateNotification'
|
|
24
|
+
|
|
25
|
+
function renderNotification() {
|
|
26
|
+
const client = new QueryClient()
|
|
27
|
+
return renderToString(
|
|
28
|
+
<QueryClientProvider client={client}>
|
|
29
|
+
<UpdateNotification />
|
|
30
|
+
</QueryClientProvider>,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('UpdateNotification', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
deploymentMode = 'local'
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps the update popup for local installations', () => {
|
|
40
|
+
const html = renderNotification()
|
|
41
|
+
expect(html).toContain('Update Available')
|
|
42
|
+
expect(html).toContain('1.3.0')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('suppresses the update popup for shared in-cluster viewers', () => {
|
|
46
|
+
deploymentMode = 'in-cluster'
|
|
47
|
+
expect(renderNotification()).toBe('')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -3,6 +3,7 @@ import { Download, X, Copy, Check, RotateCw, ArrowDownToLine, Loader2 } from 'lu
|
|
|
3
3
|
import { useQueryClient } from '@tanstack/react-query'
|
|
4
4
|
import {
|
|
5
5
|
useVersionCheck,
|
|
6
|
+
useCapabilities,
|
|
6
7
|
useStartDesktopUpdate,
|
|
7
8
|
useDesktopUpdateStatus,
|
|
8
9
|
useApplyDesktopUpdate,
|
|
@@ -14,6 +15,8 @@ const DISMISSED_KEY = 'radar-update-dismissed'
|
|
|
14
15
|
|
|
15
16
|
export function UpdateNotification() {
|
|
16
17
|
const queryClient = useQueryClient()
|
|
18
|
+
const { data: capabilities } = useCapabilities()
|
|
19
|
+
const deploymentMode = capabilities ? (capabilities.deployment?.mode ?? 'local') : undefined
|
|
17
20
|
const { data: versionInfo } = useVersionCheck()
|
|
18
21
|
const [dismissed, setDismissed] = useState(false)
|
|
19
22
|
const [copied, setCopied] = useState(false)
|
|
@@ -98,8 +101,9 @@ export function UpdateNotification() {
|
|
|
98
101
|
})
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
//
|
|
102
|
-
|
|
104
|
+
// Shared in-cluster viewers get a persistent Home notice instead of a
|
|
105
|
+
// floating action prompt they may not be able to act on.
|
|
106
|
+
if (!versionInfo?.updateAvailable || dismissed || deploymentMode === undefined || deploymentMode === 'in-cluster' || deploymentMode === 'cloud') {
|
|
103
107
|
return null
|
|
104
108
|
}
|
|
105
109
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
Activity,
|
|
9
9
|
Sun,
|
|
10
10
|
Stethoscope,
|
|
11
|
-
|
|
11
|
+
Coins,
|
|
12
12
|
Gauge,
|
|
13
13
|
ShieldCheck,
|
|
14
14
|
GitBranch,
|
|
@@ -19,15 +19,7 @@ import {
|
|
|
19
19
|
import { useNamespaces, useContexts } from "../../api/client";
|
|
20
20
|
import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
|
|
21
21
|
import { getResourceIcon } from "../../utils/resource-icons";
|
|
22
|
-
import { parseContextName } from "../../utils/context-name";
|
|
23
|
-
|
|
24
|
-
// Drop the disambiguating " (source)" suffix the context list appends, so the
|
|
25
|
-
// GKE/EKS/AKS parser sees the bare context name (mirrors the cluster picker).
|
|
26
|
-
function stripSourceSuffix(name: string, source?: string): string {
|
|
27
|
-
if (!source) return name;
|
|
28
|
-
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
-
return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), "");
|
|
30
|
-
}
|
|
22
|
+
import { parseContextForSwitcher, parseContextName } from "../../utils/context-name";
|
|
31
23
|
|
|
32
24
|
export type MainView =
|
|
33
25
|
| "home"
|
|
@@ -151,7 +143,7 @@ const VIEW_ENTRIES: {
|
|
|
151
143
|
{ view: "traffic", label: "Live Traffic", icon: Activity, shortcut: "g f" },
|
|
152
144
|
{ view: "checks", label: "Checks", icon: ShieldCheck, shortcut: "g u" },
|
|
153
145
|
{ view: "capacity", label: "Capacity", icon: Gauge, shortcut: "g p" },
|
|
154
|
-
{ view: "cost", label: "Cost", icon:
|
|
146
|
+
{ view: "cost", label: "Cost", icon: Coins, shortcut: "g c" },
|
|
155
147
|
];
|
|
156
148
|
|
|
157
149
|
// The static command-palette items (Views, Resource Kinds, Contexts,
|
|
@@ -204,9 +196,7 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
|
204
196
|
// it. Count display names so genuine duplicates (same cluster name from
|
|
205
197
|
// different kubeconfig sources) stay distinguishable; unique ones stay clean.
|
|
206
198
|
const parsedCtx = contexts.map((ctx) => {
|
|
207
|
-
const parsed =
|
|
208
|
-
stripSourceSuffix(ctx.name, ctx.source),
|
|
209
|
-
);
|
|
199
|
+
const parsed = parseContextForSwitcher(ctx);
|
|
210
200
|
const fromCluster = ctx.cluster ? parseContextName(ctx.cluster) : null;
|
|
211
201
|
const meta = [
|
|
212
202
|
parsed.provider ?? fromCluster?.provider,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { gitOpsOwnerFromRelationships } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import type { Relationships } from '../../types'
|
|
5
|
+
import { findInheritedGitOpsLookupRef } from './WorkloadView'
|
|
6
|
+
|
|
7
|
+
describe('findInheritedGitOpsLookupRef', () => {
|
|
8
|
+
it('follows a referenced ReplicaSet to its parent workload for inherited ownership', () => {
|
|
9
|
+
const relationships: Relationships = {
|
|
10
|
+
managedBy: [
|
|
11
|
+
{
|
|
12
|
+
kind: 'Deployment',
|
|
13
|
+
group: 'apps',
|
|
14
|
+
namespace: 'prod',
|
|
15
|
+
name: 'web',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
expect(
|
|
21
|
+
findInheritedGitOpsLookupRef(relationships, null, {
|
|
22
|
+
kind: 'ReplicaSet',
|
|
23
|
+
group: 'apps',
|
|
24
|
+
namespace: 'prod',
|
|
25
|
+
name: 'web-7b8d9',
|
|
26
|
+
}),
|
|
27
|
+
).toEqual({
|
|
28
|
+
kind: 'Deployment',
|
|
29
|
+
group: 'apps',
|
|
30
|
+
namespace: 'prod',
|
|
31
|
+
name: 'web',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not fetch a parent when the target has a direct GitOps owner', () => {
|
|
36
|
+
const relationships: Relationships = {
|
|
37
|
+
managedBy: [
|
|
38
|
+
{
|
|
39
|
+
kind: 'Application',
|
|
40
|
+
group: 'argoproj.io',
|
|
41
|
+
namespace: 'argocd',
|
|
42
|
+
name: 'web',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect(
|
|
48
|
+
findInheritedGitOpsLookupRef(
|
|
49
|
+
relationships,
|
|
50
|
+
gitOpsOwnerFromRelationships(relationships),
|
|
51
|
+
{
|
|
52
|
+
kind: 'Deployment',
|
|
53
|
+
group: 'apps',
|
|
54
|
+
namespace: 'prod',
|
|
55
|
+
name: 'web',
|
|
56
|
+
},
|
|
57
|
+
),
|
|
58
|
+
).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
})
|