@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
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { ContextInfo } from '../types'
|
|
4
5
|
|
|
5
6
|
vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
|
|
6
7
|
|
|
8
|
+
const useContextsMock = vi.hoisted(() => vi.fn(
|
|
9
|
+
(): { data: ContextInfo[] | undefined } => ({ data: undefined }),
|
|
10
|
+
))
|
|
11
|
+
const capabilitiesMock = vi.hoisted(() => ({ localTerminal: true }))
|
|
12
|
+
|
|
7
13
|
vi.mock('@skyhook-io/k8s-ui', () => ({
|
|
8
14
|
ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
|
|
9
15
|
useOpenLocalTerminal: () => vi.fn(),
|
|
10
16
|
}))
|
|
11
17
|
vi.mock('../api/client', () => ({
|
|
12
18
|
useAuthMe: () => ({ data: { authEnabled: false } }),
|
|
19
|
+
useContexts: useContextsMock,
|
|
20
|
+
}))
|
|
21
|
+
vi.mock('../contexts/CapabilitiesContext', () => ({
|
|
22
|
+
useCapabilitiesContext: () => capabilitiesMock,
|
|
13
23
|
}))
|
|
14
24
|
vi.mock('./ContextSwitcher', () => ({
|
|
15
25
|
ContextSwitcher: () => <button>Switch context</button>,
|
|
@@ -35,6 +45,10 @@ function renderError(errorType: string, context: string): string {
|
|
|
35
45
|
)
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
capabilitiesMock.localTerminal = true
|
|
50
|
+
})
|
|
51
|
+
|
|
38
52
|
describe('ConnectionErrorView authentication guidance', () => {
|
|
39
53
|
it('builds an honest EKS diagnostic without presenting it as authentication', () => {
|
|
40
54
|
const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
@@ -72,6 +86,34 @@ describe('ConnectionErrorView authentication guidance', () => {
|
|
|
72
86
|
expect(markup).not.toContain('aws sso login')
|
|
73
87
|
})
|
|
74
88
|
|
|
89
|
+
it('uses the original context for collision-qualified auth guidance', () => {
|
|
90
|
+
const original = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
91
|
+
const hints = selectConnectionHints('auth', `${original} (secondary)`, original)
|
|
92
|
+
|
|
93
|
+
expect(hints?.title).toBe('EKS Authentication Failed')
|
|
94
|
+
expect(hints?.fallbackCommand?.command).toBe(
|
|
95
|
+
'aws eks update-kubeconfig --name prod --region us-east-1',
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('wires the current context original name into auth guidance', () => {
|
|
100
|
+
const original = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
101
|
+
useContextsMock.mockReturnValueOnce({
|
|
102
|
+
data: [{
|
|
103
|
+
name: `${original} (secondary)`,
|
|
104
|
+
originalName: original,
|
|
105
|
+
cluster: original,
|
|
106
|
+
user: 'prod',
|
|
107
|
+
namespace: '',
|
|
108
|
+
isCurrent: true,
|
|
109
|
+
}],
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const markup = renderError('auth', `${original} (secondary)`)
|
|
113
|
+
expect(markup).toContain('update-kubeconfig')
|
|
114
|
+
expect(markup).toContain('us-east-1')
|
|
115
|
+
})
|
|
116
|
+
|
|
75
117
|
it('renders placeholder AKS commands without a run affordance', () => {
|
|
76
118
|
const markup = renderError('auth-rejected', 'clusterUser_platform_prod')
|
|
77
119
|
|
|
@@ -85,4 +127,35 @@ describe('ConnectionErrorView authentication guidance', () => {
|
|
|
85
127
|
|
|
86
128
|
expect(markup).not.toContain('aria-label="Run command in terminal"')
|
|
87
129
|
})
|
|
130
|
+
|
|
131
|
+
it('keeps recovery commands copyable without offering an unavailable local terminal', () => {
|
|
132
|
+
capabilitiesMock.localTerminal = false
|
|
133
|
+
|
|
134
|
+
const markup = renderError('auth', 'gke_project_us-east1_prod')
|
|
135
|
+
|
|
136
|
+
expect(markup).toContain('Refresh Google Cloud credentials')
|
|
137
|
+
expect(markup).toContain('>gcloud</span>')
|
|
138
|
+
expect(markup).toContain('aria-label="Copy command to clipboard"')
|
|
139
|
+
expect(markup).not.toContain('aria-label="Run command in terminal"')
|
|
140
|
+
expect(markup).not.toContain('Authenticate in terminal')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('ConnectionErrorView kubeconfig guidance', () => {
|
|
145
|
+
it('keeps the actionable error and context switch visible for a broken context', () => {
|
|
146
|
+
const markup = renderError('config', 'prod')
|
|
147
|
+
|
|
148
|
+
expect(markup).toContain('Cannot Load Cluster Configuration')
|
|
149
|
+
expect(markup).toContain('Kubeconfig Problem')
|
|
150
|
+
expect(markup).toContain('aria-expanded="false"')
|
|
151
|
+
expect(markup).toContain('id="connection-raw-error"')
|
|
152
|
+
expect(markup).toContain('safe error')
|
|
153
|
+
expect(markup).toContain('Switch context')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('does not offer context switching when no context was loaded', () => {
|
|
157
|
+
const markup = renderError('config', '')
|
|
158
|
+
|
|
159
|
+
expect(markup).not.toContain('Switch context')
|
|
160
|
+
})
|
|
88
161
|
})
|
|
@@ -4,10 +4,11 @@ import type { ConnectionState } from '../context/ConnectionContext'
|
|
|
4
4
|
import { ContextSwitcher } from './ContextSwitcher'
|
|
5
5
|
import { parseContextName } from '../utils/context-name'
|
|
6
6
|
import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
|
|
7
|
-
import { useAuthMe } from '../api/client'
|
|
7
|
+
import { useAuthMe, useContexts } from '../api/client'
|
|
8
8
|
import { Tooltip } from './ui/Tooltip'
|
|
9
9
|
import { allShellSafe } from '../utils/shell-safe'
|
|
10
10
|
import { apiUrl } from '../api/config'
|
|
11
|
+
import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
|
|
11
12
|
|
|
12
13
|
interface ConnectionErrorViewProps {
|
|
13
14
|
connection: ConnectionState
|
|
@@ -218,11 +219,11 @@ function getTimeoutHints(context: string): AuthHints | null {
|
|
|
218
219
|
|
|
219
220
|
const errorHints: Record<string, { title: string; hints: string[] }> = {
|
|
220
221
|
config: {
|
|
221
|
-
title: '
|
|
222
|
+
title: 'Kubeconfig Problem',
|
|
222
223
|
hints: [
|
|
223
|
-
'Radar could not
|
|
224
|
-
'If
|
|
225
|
-
'
|
|
224
|
+
'Radar could not load a usable kubeconfig for this context',
|
|
225
|
+
'If the file exists, check the local Radar logs for the exact parse or load failure',
|
|
226
|
+
'If no kubeconfig is configured, Radar checks ~/.kube/config; set KUBECONFIG or pass --kubeconfig <path> for another location',
|
|
226
227
|
],
|
|
227
228
|
},
|
|
228
229
|
rbac: {
|
|
@@ -321,16 +322,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
|
|
|
321
322
|
)
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
export function selectConnectionHints(errorType: string | undefined, context: string): AuthHints | null {
|
|
325
|
+
export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
|
|
326
|
+
const parsedContext = originalContext || context
|
|
325
327
|
switch (errorType) {
|
|
326
328
|
case 'auth':
|
|
327
|
-
return getAuthHints(
|
|
329
|
+
return getAuthHints(parsedContext)
|
|
328
330
|
case 'auth-rejected':
|
|
329
|
-
return getAuthRejectedHints(
|
|
331
|
+
return getAuthRejectedHints(parsedContext)
|
|
330
332
|
case 'auth-plugin-stuck':
|
|
331
333
|
return getAuthPluginStuckHints()
|
|
332
334
|
case 'timeout':
|
|
333
|
-
return getTimeoutHints(
|
|
335
|
+
return getTimeoutHints(parsedContext)
|
|
334
336
|
default:
|
|
335
337
|
return null
|
|
336
338
|
}
|
|
@@ -342,10 +344,13 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
342
344
|
const isAuthRejected = connection.errorType === 'auth-rejected'
|
|
343
345
|
const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
|
|
344
346
|
const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
|
|
345
|
-
const
|
|
347
|
+
const { data: contexts } = useContexts()
|
|
348
|
+
const originalContext = contexts?.find((context) => context.name === connection.context)?.originalName
|
|
349
|
+
const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext)
|
|
346
350
|
const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
|
|
347
351
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
348
352
|
const { data: authMe } = useAuthMe()
|
|
353
|
+
const { localTerminal } = useCapabilitiesContext()
|
|
349
354
|
const rawErrorDefaultOpen = !connection.errorType || connection.errorType === 'unknown'
|
|
350
355
|
const [showRawError, setShowRawError] = useState(rawErrorDefaultOpen)
|
|
351
356
|
|
|
@@ -353,11 +358,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
353
358
|
setShowRawError(rawErrorDefaultOpen)
|
|
354
359
|
}, [connection.error, rawErrorDefaultOpen])
|
|
355
360
|
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
// mode — but the chained retry curl carries no session cookie, so it 401s
|
|
359
|
-
// once /api/connection is auth-gated. Only chain it when auth is *known*
|
|
360
|
-
// disabled (authMe still loading → don't chain a doomed call).
|
|
361
|
+
// The local terminal is only available in unauthenticated local mode, but
|
|
362
|
+
// authMe may still be loading when the capability response arrives.
|
|
361
363
|
const retryCmd = `curl -s -X POST http://${window.location.host}${apiUrl('/connection/retry')} > /dev/null`
|
|
362
364
|
|
|
363
365
|
const handleAuthInTerminal = () => {
|
|
@@ -384,7 +386,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
384
386
|
</div>
|
|
385
387
|
|
|
386
388
|
<h2 className="text-xl font-semibold text-theme-text-primary mb-2">
|
|
387
|
-
{connection.errorType === 'config' ? '
|
|
389
|
+
{connection.errorType === 'config' ? 'Cannot Load Cluster Configuration' : 'Cannot Connect to Cluster'}
|
|
388
390
|
</h2>
|
|
389
391
|
|
|
390
392
|
<div className="mb-6 space-y-1">
|
|
@@ -418,8 +420,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
418
420
|
{commandInfo?.authCommand && (
|
|
419
421
|
<div className="mt-3">
|
|
420
422
|
<p className="text-xs text-theme-text-tertiary">{commandInfo.authCommand.label}</p>
|
|
421
|
-
<CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
422
|
-
{isAuthError && !commandInfo?.hideAuthButton && commandInfo.authCommand.runnable !== false && (
|
|
423
|
+
<CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={!localTerminal || commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
424
|
+
{localTerminal && isAuthError && !commandInfo?.hideAuthButton && commandInfo.authCommand.runnable !== false && (
|
|
423
425
|
<button
|
|
424
426
|
onClick={handleAuthInTerminal}
|
|
425
427
|
className="mt-3 w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium btn-brand rounded-md"
|
|
@@ -433,7 +435,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
433
435
|
{commandInfo?.fallbackCommand && (
|
|
434
436
|
<div className="mt-4 pt-3 border-t border-theme-border/50">
|
|
435
437
|
<p className="text-xs text-theme-text-tertiary">{commandInfo.fallbackCommand.label}</p>
|
|
436
|
-
<CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={commandInfo.fallbackCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
438
|
+
<CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={!localTerminal || commandInfo.fallbackCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
437
439
|
</div>
|
|
438
440
|
)}
|
|
439
441
|
{connection.error && (
|
|
@@ -480,7 +482,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
480
482
|
)}
|
|
481
483
|
</button>
|
|
482
484
|
|
|
483
|
-
{connection.
|
|
485
|
+
{connection.context && <ContextSwitcher triggerName="Switch context" />}
|
|
484
486
|
</div>
|
|
485
487
|
|
|
486
488
|
{isAuthError && (
|
|
@@ -10,7 +10,7 @@ import { useContextSwitch } from '../context/ContextSwitchContext'
|
|
|
10
10
|
import { useToast } from '../components/ui/Toast'
|
|
11
11
|
import { useDock } from '../components/dock'
|
|
12
12
|
import type { ContextInfo } from '../types'
|
|
13
|
-
import {
|
|
13
|
+
import { parseContextForSwitcher, visibleContextQualifier, type ParsedContextName } from '../utils/context-name'
|
|
14
14
|
|
|
15
15
|
interface ContextSwitcherProps {
|
|
16
16
|
className?: string
|
|
@@ -25,6 +25,7 @@ export interface ContextSwitcherHandle {
|
|
|
25
25
|
|
|
26
26
|
interface ParsedContext extends ParsedContextName {
|
|
27
27
|
context: ContextInfo
|
|
28
|
+
nameQualifier?: string
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function shouldSuppressSwitchErrorToast(error: unknown): boolean {
|
|
@@ -54,18 +55,9 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
|
|
|
54
55
|
hasMultipleAccounts: false,
|
|
55
56
|
hasMultipleSources: false,
|
|
56
57
|
}
|
|
57
|
-
// Strip the disambiguation suffix (" (<source>)" or " (<source> #N)")
|
|
58
|
-
// before parsing — qualified names won't match the GKE/EKS/AKS regexes
|
|
59
|
-
// otherwise, and the suffix is redundant with the source chip we
|
|
60
|
-
// render separately.
|
|
61
|
-
const stripSourceSuffix = (name: string, source?: string): string => {
|
|
62
|
-
if (!source) return name
|
|
63
|
-
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
64
|
-
return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
|
|
65
|
-
}
|
|
66
58
|
const parsed: ParsedContext[] = contexts.map(ctx => ({
|
|
67
59
|
context: ctx,
|
|
68
|
-
...
|
|
60
|
+
...parseContextForSwitcher(ctx),
|
|
69
61
|
}))
|
|
70
62
|
const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
|
|
71
63
|
const sources = new Set(contexts.map(c => c.source).filter(Boolean))
|
|
@@ -100,6 +92,7 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
|
|
|
100
92
|
return {
|
|
101
93
|
id: p.context.name,
|
|
102
94
|
name: p.raw,
|
|
95
|
+
nameQualifier: visibleContextQualifier(p.nameQualifier, p.context.source, hasMultipleSources),
|
|
103
96
|
secondary: p.provider ? p.raw : undefined,
|
|
104
97
|
badge: p.region || undefined,
|
|
105
98
|
sourceLabel: hasMultipleSources ? p.context.source : undefined,
|
|
@@ -188,12 +181,15 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
|
|
|
188
181
|
|
|
189
182
|
const currentCtx = contexts?.find(c => c.isCurrent)
|
|
190
183
|
const currentId = currentCtx?.name
|
|
191
|
-
//
|
|
192
|
-
//
|
|
184
|
+
// Keep the trigger name parseable and render any collision qualifier as a
|
|
185
|
+
// separate suffix so cloud-provider metadata remains intact.
|
|
193
186
|
// Fall back to clusterInfo.context for the very-early window before
|
|
194
187
|
// /api/contexts has resolved.
|
|
195
188
|
const currentParsed = currentId ? parsedById.get(currentId) : undefined
|
|
196
189
|
const currentRaw = triggerName || currentParsed?.raw || clusterInfo?.context || currentCtx?.name || 'Unknown'
|
|
190
|
+
const currentNameQualifier = triggerName
|
|
191
|
+
? undefined
|
|
192
|
+
: visibleContextQualifier(currentParsed?.nameQualifier, currentCtx?.source, hasMultipleSources)
|
|
197
193
|
const currentSourceLabel = triggerName ? undefined : hasMultipleSources ? currentCtx?.source || undefined : undefined
|
|
198
194
|
|
|
199
195
|
return (
|
|
@@ -205,6 +201,7 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
|
|
|
205
201
|
label={label}
|
|
206
202
|
currentId={currentId}
|
|
207
203
|
currentName={currentRaw}
|
|
204
|
+
currentNameQualifier={currentNameQualifier}
|
|
208
205
|
currentSourceLabel={currentSourceLabel}
|
|
209
206
|
items={items}
|
|
210
207
|
onSelect={handleSelect}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
eventsForApplication,
|
|
23
23
|
memberRef,
|
|
24
24
|
subjectRef,
|
|
25
|
+
compareIssueSortAnchors,
|
|
25
26
|
type AppRow,
|
|
26
27
|
type AppWorkload,
|
|
27
28
|
type AppIdentityInstance,
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
} from "../../api/client";
|
|
47
48
|
import { useConnection } from "../../context/ConnectionContext";
|
|
48
49
|
import { useTimelineSource } from "../../context/TimelineSource";
|
|
49
|
-
import { buildWorkloadPath,
|
|
50
|
+
import { apiVersionToGroup, buildWorkloadPath, kindToPluralWithGroup } from "../../utils/navigation";
|
|
50
51
|
import { WorkloadView } from "../workload/WorkloadView";
|
|
51
52
|
import { ApplicationCostTab } from "../cost/ApplicationCostTab";
|
|
52
53
|
import { isOpenCostWorkloadKind } from "../cost/kinds";
|
|
@@ -457,7 +458,7 @@ function AppDetailRoute({
|
|
|
457
458
|
params.set("workload", workloadKey(workload));
|
|
458
459
|
params.set(
|
|
459
460
|
"run",
|
|
460
|
-
`${
|
|
461
|
+
`${kindToPluralWithGroup(run.kind, apiVersionToGroup(run.data?.apiVersion as string | undefined))}/${runNamespace}/${run.name}`,
|
|
461
462
|
);
|
|
462
463
|
setSearchParams(params);
|
|
463
464
|
},
|
|
@@ -465,14 +466,15 @@ function AppDetailRoute({
|
|
|
465
466
|
);
|
|
466
467
|
const openWorkloadResource = useCallback(
|
|
467
468
|
(resource: SelectedResource) => {
|
|
468
|
-
|
|
469
|
+
const pluralKind = kindToPluralWithGroup(resource.kind, resource.group ?? "")
|
|
470
|
+
if (pluralKind.toLowerCase() !== "pods") {
|
|
469
471
|
onOpenResource(resource);
|
|
470
472
|
return;
|
|
471
473
|
}
|
|
472
474
|
|
|
473
475
|
const [pathname, rawSearch = ""] = buildWorkloadPath({
|
|
474
476
|
...resource,
|
|
475
|
-
kind:
|
|
477
|
+
kind: pluralKind,
|
|
476
478
|
}).split("?");
|
|
477
479
|
const params = new URLSearchParams(rawSearch);
|
|
478
480
|
const activeNamespaces = searchParams.get("namespaces");
|
|
@@ -682,7 +684,7 @@ function AppDetailRoute({
|
|
|
682
684
|
renderWorkload={(workload: SelectedAppWorkload) => (
|
|
683
685
|
<div className="h-full overflow-hidden">
|
|
684
686
|
<WorkloadView
|
|
685
|
-
kind={
|
|
687
|
+
kind={kindToPluralWithGroup(workload.kind, workload.group ?? "")}
|
|
686
688
|
group={workload.group}
|
|
687
689
|
namespace={workload.namespace}
|
|
688
690
|
name={workload.name}
|
|
@@ -837,7 +839,7 @@ function AppOverviewIssueRows({
|
|
|
837
839
|
}) {
|
|
838
840
|
const navigate = (ref: IssueResourceRef) => {
|
|
839
841
|
onOpenResource({
|
|
840
|
-
kind:
|
|
842
|
+
kind: kindToPluralWithGroup(ref.kind, ref.group ?? ""),
|
|
841
843
|
namespace: ref.namespace ?? "",
|
|
842
844
|
name: ref.name,
|
|
843
845
|
group: ref.group ?? "",
|
|
@@ -888,9 +890,8 @@ function compareAppOverviewIssues(a: Issue, b: Issue): number {
|
|
|
888
890
|
const severity =
|
|
889
891
|
ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
|
|
890
892
|
if (severity !== 0) return severity;
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
if (fa !== fb) return fb.localeCompare(fa);
|
|
893
|
+
const onset = compareIssueSortAnchors(a, b);
|
|
894
|
+
if (onset !== 0) return onset;
|
|
894
895
|
const ns = (a.namespace ?? "").localeCompare(b.namespace ?? "");
|
|
895
896
|
if (ns !== 0) return ns;
|
|
896
897
|
const name = a.name.localeCompare(b.name);
|
|
@@ -88,9 +88,14 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
88
88
|
|
|
89
89
|
return (
|
|
90
90
|
<div className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
|
|
91
|
+
{/* Tabs above the header: the header describes best practices only, so
|
|
92
|
+
tabs rendered below it read as part of that page rather than as the
|
|
93
|
+
switch between the two Checks surfaces. */}
|
|
94
|
+
<ChecksViewTabs />
|
|
95
|
+
|
|
91
96
|
<PageHeader
|
|
92
97
|
icon={ShieldCheck}
|
|
93
|
-
title="
|
|
98
|
+
title="Best practices"
|
|
94
99
|
description="Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue."
|
|
95
100
|
actions={
|
|
96
101
|
<>
|
|
@@ -115,8 +120,6 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
115
120
|
}
|
|
116
121
|
/>
|
|
117
122
|
|
|
118
|
-
<ChecksViewTabs />
|
|
119
|
-
|
|
120
123
|
<ChecksView
|
|
121
124
|
checks={data.groupedChecks ?? []}
|
|
122
125
|
catalog={data.checks ?? {}}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
3
|
import { ApiError } from '../../api/client'
|
|
4
|
-
import { UPGRADE_IMPACT_DOCS_URL, UPGRADE_IMPACT_MIN_RADAR_VERSION, UpgradeReadinessError, groupFindings, incompleteUpgradeCheckCount, issueSpecificReferences, summaryMeta, upgradeEvaluationSummary } from './UpgradeReadinessView'
|
|
4
|
+
import { UPGRADE_IMPACT_DOCS_URL, UPGRADE_IMPACT_MIN_RADAR_VERSION, UpgradeReadinessError, groupFindings, incompleteUpgradeCheckCount, issueSpecificReferences, summaryMeta, upgradeEvaluationSummary, upgradeEvidenceCoverageLabel, upgradeUnavailableKindsMessage } from './UpgradeReadinessView'
|
|
5
5
|
|
|
6
6
|
describe('UpgradeReadinessError', () => {
|
|
7
7
|
it('maps an unmatched endpoint 404 to the v1.9 upgrade message', () => {
|
|
@@ -40,7 +40,7 @@ describe('upgradeEvaluationSummary', () => {
|
|
|
40
40
|
unknown: 1,
|
|
41
41
|
notApplicable: 2,
|
|
42
42
|
findings: 1,
|
|
43
|
-
})).toBe('18 evaluated · 16 applicable · 1 with
|
|
43
|
+
})).toBe('18 evaluated · 16 applicable · 1 with incomplete evidence · 2 not applicable')
|
|
44
44
|
})
|
|
45
45
|
|
|
46
46
|
it('does not imply incomplete coverage when every applicable check completed', () => {
|
|
@@ -68,6 +68,30 @@ describe('upgradeEvaluationSummary', () => {
|
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
describe('upgradeUnavailableKindsMessage', () => {
|
|
72
|
+
it('does not guess why webhook evidence is unavailable', () => {
|
|
73
|
+
const message = upgradeUnavailableKindsMessage(['mutatingwebhookconfigurations', 'validatingwebhookconfigurations'])
|
|
74
|
+
|
|
75
|
+
expect(message).toContain('Affected checks are marked incomplete')
|
|
76
|
+
expect(message).not.toContain('rbac.viewWebhooks')
|
|
77
|
+
expect(message).not.toContain('grant')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('does not suggest webhook access for unrelated evidence gaps', () => {
|
|
81
|
+
expect(upgradeUnavailableKindsMessage(['apiservices'])).not.toContain('rbac.viewWebhooks')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('upgradeEvidenceCoverageLabel', () => {
|
|
86
|
+
it('distinguishes absent evidence from partial evidence', () => {
|
|
87
|
+
expect(upgradeEvidenceCoverageLabel({ status: 'unknown', caveat: 'unavailable' })).toBe('No evidence')
|
|
88
|
+
expect(upgradeEvidenceCoverageLabel({ status: 'unknown', inspected: 0, caveat: 'unavailable' })).toBe('No evidence')
|
|
89
|
+
expect(upgradeEvidenceCoverageLabel({ status: 'unknown', inspected: 1, caveat: 'one object malformed' })).toBe('Partial evidence')
|
|
90
|
+
expect(upgradeEvidenceCoverageLabel({ status: 'blocked', inspected: 1, caveat: 'some nodes unavailable' })).toBe('Partial evidence')
|
|
91
|
+
expect(upgradeEvidenceCoverageLabel({ status: 'passed', inspected: 1 })).toBe('')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
71
95
|
describe('groupFindings', () => {
|
|
72
96
|
it('groups repeated remediation inside a check without merging distinct action levels', () => {
|
|
73
97
|
const base = {
|
|
@@ -76,7 +76,7 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
|
|
|
76
76
|
const location = useLocation()
|
|
77
77
|
const navigate = useNavigate()
|
|
78
78
|
const requestedTarget = new URLSearchParams(location.search).get('target') ?? undefined
|
|
79
|
-
const { data, isLoading, isFetching, isPlaceholderData, error, dataUpdatedAt,
|
|
79
|
+
const { data, isLoading, isFetching, isPlaceholderData, error, dataUpdatedAt, refreshScan } = useUpgradeReadiness(requestedTarget)
|
|
80
80
|
const { connection } = useConnection()
|
|
81
81
|
const targetOptions = useMemo(
|
|
82
82
|
() => buildTargetOptions(data?.currentVersion, data?.reviewedThrough, requestedTarget ?? data?.targetVersion),
|
|
@@ -127,6 +127,8 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
|
|
|
127
127
|
|
|
128
128
|
return (
|
|
129
129
|
<div aria-busy={isFetching} className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
|
|
130
|
+
<ChecksViewTabs />
|
|
131
|
+
|
|
130
132
|
<PageHeader
|
|
131
133
|
icon={ShieldAlert}
|
|
132
134
|
title="Upgrade impact"
|
|
@@ -153,8 +155,8 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
|
|
|
153
155
|
)}
|
|
154
156
|
<FreshnessControl
|
|
155
157
|
mode="snapshot"
|
|
156
|
-
dataUpdatedAt={dataUpdatedAt}
|
|
157
|
-
onRefresh={() =>
|
|
158
|
+
dataUpdatedAt={data.observedAt ? Date.parse(data.observedAt) : dataUpdatedAt}
|
|
159
|
+
onRefresh={() => refreshScan()}
|
|
158
160
|
connectionState={connection.state}
|
|
159
161
|
isFetching={isFetching}
|
|
160
162
|
/>
|
|
@@ -162,8 +164,6 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
|
|
|
162
164
|
}
|
|
163
165
|
/>
|
|
164
166
|
|
|
165
|
-
<ChecksViewTabs />
|
|
166
|
-
|
|
167
167
|
{showingPreviousTarget && (
|
|
168
168
|
<CoverageNotice
|
|
169
169
|
headline={`Analyzing Kubernetes ${requestedTarget}`}
|
|
@@ -202,7 +202,7 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
|
|
|
202
202
|
{data.coverage.state !== 'no_access' && (data.coverage.unavailableKinds?.length ?? 0) > 0 && (
|
|
203
203
|
<CoverageNotice
|
|
204
204
|
headline="Some live resources were unavailable"
|
|
205
|
-
body={
|
|
205
|
+
body={upgradeUnavailableKindsMessage(data.coverage.unavailableKinds ?? [])}
|
|
206
206
|
/>
|
|
207
207
|
)}
|
|
208
208
|
{data.coverage.state !== 'no_access' && scopedKinds.length > 0 && (
|
|
@@ -212,7 +212,7 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
|
|
|
212
212
|
/>
|
|
213
213
|
)}
|
|
214
214
|
{data.coverage.state === 'partial' && !data.coverage.scopedNamespaces?.length && !data.coverage.unavailableKinds?.length && scopedKinds.length === 0 && (
|
|
215
|
-
<CoverageNotice headline="Some evidence is incomplete" body="
|
|
215
|
+
<CoverageNotice headline="Some evidence is incomplete" body="Evidence labels on affected rows explain what Radar could not verify." />
|
|
216
216
|
)}
|
|
217
217
|
{data.coverage.state === 'no_access' ? (
|
|
218
218
|
<section className="shrink-0">
|
|
@@ -291,8 +291,17 @@ export function incompleteUpgradeCheckCount(checks: Pick<UpgradeReadinessCheck,
|
|
|
291
291
|
|
|
292
292
|
export function upgradeEvaluationSummary(total: number, summary: UpgradeReadinessResponse['summary'], incomplete = summary.unknown) {
|
|
293
293
|
const applicable = Math.max(0, total - summary.notApplicable)
|
|
294
|
-
const
|
|
295
|
-
return `${total} evaluated · ${applicable} applicable${
|
|
294
|
+
const incompleteEvidenceLabel = incomplete > 0 ? ` · ${incomplete} with incomplete evidence` : ''
|
|
295
|
+
return `${total} evaluated · ${applicable} applicable${incompleteEvidenceLabel} · ${summary.notApplicable} not applicable`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function upgradeUnavailableKindsMessage(unavailableKinds: string[]) {
|
|
299
|
+
return `Radar could not inspect: ${unavailableKinds.join(', ')}. Affected checks are marked incomplete.`
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function upgradeEvidenceCoverageLabel(check: Pick<UpgradeReadinessCheck, 'status' | 'inspected' | 'caveat'>) {
|
|
303
|
+
if (!check.caveat) return ''
|
|
304
|
+
return check.status === 'unknown' && (check.inspected ?? 0) === 0 ? 'No evidence' : 'Partial evidence'
|
|
296
305
|
}
|
|
297
306
|
|
|
298
307
|
function CoverageMethodology({ data }: { data: UpgradeReadinessResponse }) {
|
|
@@ -323,7 +332,7 @@ function CoverageMethodology({ data }: { data: UpgradeReadinessResponse }) {
|
|
|
323
332
|
</p>
|
|
324
333
|
<p>
|
|
325
334
|
Results use live cluster resources and the row-specific evidence scope shown below. {unavailable.length > 0
|
|
326
|
-
?
|
|
335
|
+
? upgradeUnavailableKindsMessage(unavailable)
|
|
327
336
|
: scopedKinds.length > 0
|
|
328
337
|
? `Cached evidence has per-kind namespace ceilings for ${formatScopedKinds(scopedKinds)}.`
|
|
329
338
|
: data.coverage.state === 'complete'
|
|
@@ -433,6 +442,7 @@ function CheckRow({ check, onNavigateToResource }: { check: UpgradeReadinessChec
|
|
|
433
442
|
const detailID = `upgrade-check-${check.id}`
|
|
434
443
|
const findingGroups = groupFindings(check.findings)
|
|
435
444
|
const label = evidenceLabel(check)
|
|
445
|
+
const coverageLabel = upgradeEvidenceCoverageLabel(check)
|
|
436
446
|
const checkReferences = check.references ?? []
|
|
437
447
|
return (
|
|
438
448
|
<div>
|
|
@@ -456,10 +466,10 @@ function CheckRow({ check, onNavigateToResource }: { check: UpgradeReadinessChec
|
|
|
456
466
|
<div className="min-w-0">
|
|
457
467
|
<Badge severity={meta.badgeSeverity}>{meta.label}</Badge>
|
|
458
468
|
{label && <div className="mt-1 text-[11px] text-theme-text-tertiary">{label}</div>}
|
|
459
|
-
{
|
|
469
|
+
{coverageLabel && (
|
|
460
470
|
<div className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
|
|
461
471
|
<AlertTriangle className="h-3 w-3 shrink-0" />
|
|
462
|
-
|
|
472
|
+
{coverageLabel}
|
|
463
473
|
</div>
|
|
464
474
|
)}
|
|
465
475
|
</div>
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
|
|
8
8
|
import {
|
|
9
9
|
parseCPUToNanocores,
|
|
10
|
-
|
|
10
|
+
parseQuantityToNumber,
|
|
11
11
|
} from "@skyhook-io/k8s-ui/utils/format";
|
|
12
12
|
import {
|
|
13
13
|
CertaintyGlyph,
|
|
@@ -96,16 +96,15 @@ export function quantityToNumber(
|
|
|
96
96
|
}
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
|
-
//
|
|
100
|
-
// would read
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const parsed =
|
|
104
|
-
if (
|
|
99
|
+
// The shared parser knows the full suffix table — the byte parser stops at
|
|
100
|
+
// Ti, so a 1PiB aggregate would read as 1 byte. It returns 0 for input it
|
|
101
|
+
// cannot parse, so distinguish a real zero to preserve the null (= unknown,
|
|
102
|
+
// not zero) contract of this card.
|
|
103
|
+
const parsed = parseQuantityToNumber(value);
|
|
104
|
+
if (parsed !== 0 || /^[+-]?[0.]+\D*$/.test(value)) {
|
|
105
105
|
return parsed;
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
return Number.isFinite(plain) ? plain : null;
|
|
107
|
+
return null;
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
function worstCertainty(
|
|
@@ -202,6 +202,16 @@ describe("quantityToNumber", () => {
|
|
|
202
202
|
expect(quantityToNumber("example.com/widget", "3000m")).toBe(3);
|
|
203
203
|
expect(quantityToNumber("cpu", "1500m")).toBe(1.5e9);
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
// Aggregate memory on a large cluster reaches suffixes the byte parser
|
|
207
|
+
// never knew (its table stops at Ti) — "1Pi" must not read as 1 byte.
|
|
208
|
+
it("parses the large suffixes an aggregate capacity can carry", () => {
|
|
209
|
+
expect(quantityToNumber("memory", "1Pi")).toBe(1024 ** 5);
|
|
210
|
+
expect(quantityToNumber("memory", "1Ei")).toBe(1024 ** 6);
|
|
211
|
+
expect(quantityToNumber("pods", "1k")).toBe(1000);
|
|
212
|
+
expect(quantityToNumber("pods", "1e3")).toBe(1000);
|
|
213
|
+
expect(quantityToNumber("memory", "0Gi")).toBe(0);
|
|
214
|
+
});
|
|
205
215
|
});
|
|
206
216
|
|
|
207
217
|
describe("claimStagesDetail", () => {
|
|
@@ -11,6 +11,7 @@ describe('getApplicationCostState', () => {
|
|
|
11
11
|
it('keeps current app cost visible when historical owner metrics are missing', () => {
|
|
12
12
|
const current: OpenCostApplicationCostResponse = {
|
|
13
13
|
available: true,
|
|
14
|
+
currency: 'USD',
|
|
14
15
|
partial: true,
|
|
15
16
|
totals: {
|
|
16
17
|
hourlyCost: 0.4,
|
|
@@ -38,6 +39,7 @@ describe('getApplicationCostState', () => {
|
|
|
38
39
|
}
|
|
39
40
|
const trend: OpenCostApplicationCostTrendResponse = {
|
|
40
41
|
available: false,
|
|
42
|
+
currency: 'USD',
|
|
41
43
|
reason: 'no_metrics',
|
|
42
44
|
range: '24h',
|
|
43
45
|
coverage: { total: 3, included: 0 },
|
|
@@ -54,6 +56,7 @@ describe('getApplicationCostState', () => {
|
|
|
54
56
|
it('uses historical data when current app metrics are absent but history exists', () => {
|
|
55
57
|
const current: OpenCostApplicationCostResponse = {
|
|
56
58
|
available: false,
|
|
59
|
+
currency: 'USD',
|
|
57
60
|
reason: 'no_metrics',
|
|
58
61
|
totals: {
|
|
59
62
|
hourlyCost: 0,
|
|
@@ -69,6 +72,7 @@ describe('getApplicationCostState', () => {
|
|
|
69
72
|
}
|
|
70
73
|
const trend: OpenCostApplicationCostTrendResponse = {
|
|
71
74
|
available: true,
|
|
75
|
+
currency: 'USD',
|
|
72
76
|
range: '7d',
|
|
73
77
|
windowTotalCost: 2,
|
|
74
78
|
dataPoints: [
|
|
@@ -89,6 +93,7 @@ describe('getApplicationCostState', () => {
|
|
|
89
93
|
it('treats all tracked workloads scaled to zero as valid zero state', () => {
|
|
90
94
|
const current: OpenCostApplicationCostResponse = {
|
|
91
95
|
available: true,
|
|
96
|
+
currency: 'USD',
|
|
92
97
|
totals: {
|
|
93
98
|
hourlyCost: 0,
|
|
94
99
|
cpuCost: 0,
|
|
@@ -168,6 +173,7 @@ describe('getApplicationCostState', () => {
|
|
|
168
173
|
it('surfaces app workload access and existence failures', () => {
|
|
169
174
|
const current: OpenCostApplicationCostResponse = {
|
|
170
175
|
available: false,
|
|
176
|
+
currency: 'USD',
|
|
171
177
|
reason: 'access_denied',
|
|
172
178
|
totals: {
|
|
173
179
|
hourlyCost: 0,
|