@skyhook-io/radar-app 1.12.2 → 1.13.0
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 +6 -6
- package/src/App.tsx +35 -15
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +246 -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 +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/cost/ApplicationCostTab.test.ts +45 -0
- package/src/components/cost/ApplicationCostTab.tsx +115 -64
- package/src/components/cost/CostTrendChart.test.ts +65 -0
- package/src/components/cost/CostTrendChart.tsx +107 -17
- package/src/components/cost/CostView.test.ts +36 -1
- package/src/components/cost/CostView.tsx +133 -51
- package/src/components/cost/CurrentAllocationUse.tsx +8 -4
- package/src/components/cost/WorkloadCostTab.test.ts +50 -0
- package/src/components/cost/WorkloadCostTab.tsx +109 -61
- package/src/components/cost/source.test.ts +33 -0
- package/src/components/cost/source.ts +100 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +19 -15
- 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/CostCard.tsx +3 -2
- 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/rightsizing/RightsizingScanView.tsx +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +687 -107
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- 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/index.css +11 -1
- 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
|
@@ -1,6 +1,6 @@
|
|
|
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
4
|
import type { ContextInfo } from '../types'
|
|
5
5
|
|
|
6
6
|
vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
|
|
@@ -8,6 +8,7 @@ vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
|
|
|
8
8
|
const useContextsMock = vi.hoisted(() => vi.fn(
|
|
9
9
|
(): { data: ContextInfo[] | undefined } => ({ data: undefined }),
|
|
10
10
|
))
|
|
11
|
+
const capabilitiesMock = vi.hoisted(() => ({ localTerminal: true }))
|
|
11
12
|
|
|
12
13
|
vi.mock('@skyhook-io/k8s-ui', () => ({
|
|
13
14
|
ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
|
|
@@ -17,6 +18,9 @@ vi.mock('../api/client', () => ({
|
|
|
17
18
|
useAuthMe: () => ({ data: { authEnabled: false } }),
|
|
18
19
|
useContexts: useContextsMock,
|
|
19
20
|
}))
|
|
21
|
+
vi.mock('../contexts/CapabilitiesContext', () => ({
|
|
22
|
+
useCapabilitiesContext: () => capabilitiesMock,
|
|
23
|
+
}))
|
|
20
24
|
vi.mock('./ContextSwitcher', () => ({
|
|
21
25
|
ContextSwitcher: () => <button>Switch context</button>,
|
|
22
26
|
}))
|
|
@@ -41,6 +45,10 @@ function renderError(errorType: string, context: string): string {
|
|
|
41
45
|
)
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
capabilitiesMock.localTerminal = true
|
|
50
|
+
})
|
|
51
|
+
|
|
44
52
|
describe('ConnectionErrorView authentication guidance', () => {
|
|
45
53
|
it('builds an honest EKS diagnostic without presenting it as authentication', () => {
|
|
46
54
|
const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
@@ -119,6 +127,18 @@ describe('ConnectionErrorView authentication guidance', () => {
|
|
|
119
127
|
|
|
120
128
|
expect(markup).not.toContain('aria-label="Run command in terminal"')
|
|
121
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
|
+
})
|
|
122
142
|
})
|
|
123
143
|
|
|
124
144
|
describe('ConnectionErrorView kubeconfig guidance', () => {
|
|
@@ -8,6 +8,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
|
|
@@ -349,6 +350,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
349
350
|
const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
|
|
350
351
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
351
352
|
const { data: authMe } = useAuthMe()
|
|
353
|
+
const { localTerminal } = useCapabilitiesContext()
|
|
352
354
|
const rawErrorDefaultOpen = !connection.errorType || connection.errorType === 'unknown'
|
|
353
355
|
const [showRawError, setShowRawError] = useState(rawErrorDefaultOpen)
|
|
354
356
|
|
|
@@ -356,11 +358,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
356
358
|
setShowRawError(rawErrorDefaultOpen)
|
|
357
359
|
}, [connection.error, rawErrorDefaultOpen])
|
|
358
360
|
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
// mode — but the chained retry curl carries no session cookie, so it 401s
|
|
362
|
-
// once /api/connection is auth-gated. Only chain it when auth is *known*
|
|
363
|
-
// 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.
|
|
364
363
|
const retryCmd = `curl -s -X POST http://${window.location.host}${apiUrl('/connection/retry')} > /dev/null`
|
|
365
364
|
|
|
366
365
|
const handleAuthInTerminal = () => {
|
|
@@ -421,8 +420,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
421
420
|
{commandInfo?.authCommand && (
|
|
422
421
|
<div className="mt-3">
|
|
423
422
|
<p className="text-xs text-theme-text-tertiary">{commandInfo.authCommand.label}</p>
|
|
424
|
-
<CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
425
|
-
{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 && (
|
|
426
425
|
<button
|
|
427
426
|
onClick={handleAuthInTerminal}
|
|
428
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"
|
|
@@ -436,7 +435,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
436
435
|
{commandInfo?.fallbackCommand && (
|
|
437
436
|
<div className="mt-4 pt-3 border-t border-theme-border/50">
|
|
438
437
|
<p className="text-xs text-theme-text-tertiary">{commandInfo.fallbackCommand.label}</p>
|
|
439
|
-
<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} />
|
|
440
439
|
</div>
|
|
441
440
|
)}
|
|
442
441
|
{connection.error && (
|
|
@@ -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 = {
|
|
@@ -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"
|
|
@@ -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>
|
|
@@ -53,6 +53,48 @@ describe('getApplicationCostState', () => {
|
|
|
53
53
|
).toBe('partial_missing_history')
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
+
it('keeps Kubecost application totals visible when history is unsupported', () => {
|
|
57
|
+
const current: OpenCostApplicationCostResponse = {
|
|
58
|
+
available: true,
|
|
59
|
+
source: 'kubecost',
|
|
60
|
+
currency: 'USD',
|
|
61
|
+
totals: {
|
|
62
|
+
hourlyCost: 0.4, cpuCost: 0.25, memoryCost: 0.15, replicas: 3,
|
|
63
|
+
cpuUsageAvailable: true, memoryUsageAvailable: true,
|
|
64
|
+
cpuAllocationUse: 40, memoryAllocationUse: 60,
|
|
65
|
+
},
|
|
66
|
+
coverage: { total: 1, included: 1 },
|
|
67
|
+
workloads: [],
|
|
68
|
+
}
|
|
69
|
+
const trend: OpenCostApplicationCostTrendResponse = {
|
|
70
|
+
available: false,
|
|
71
|
+
source: 'kubecost',
|
|
72
|
+
reason: 'history_unsupported',
|
|
73
|
+
currency: 'USD',
|
|
74
|
+
range: '24h',
|
|
75
|
+
coverage: { total: 1, included: 0 },
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
expect(getApplicationCostState(current, trend, {})).toBe('partial_missing_history')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does not let unsupported history mask current loading or errors', () => {
|
|
82
|
+
const trend: OpenCostApplicationCostTrendResponse = {
|
|
83
|
+
available: false,
|
|
84
|
+
source: 'kubecost',
|
|
85
|
+
reason: 'history_unsupported',
|
|
86
|
+
currency: 'USD',
|
|
87
|
+
range: '24h',
|
|
88
|
+
coverage: { total: 1, included: 0 },
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(getApplicationCostState(undefined, trend, { currentLoading: true })).toBe('loading')
|
|
92
|
+
expect(getApplicationCostState(undefined, trend, { currentError: true })).toBe('load_error')
|
|
93
|
+
expect(
|
|
94
|
+
getApplicationCostState(undefined, trend, { currentError: new ApiError('denied', 403) }),
|
|
95
|
+
).toBe('access_denied')
|
|
96
|
+
})
|
|
97
|
+
|
|
56
98
|
it('uses historical data when current app metrics are absent but history exists', () => {
|
|
57
99
|
const current: OpenCostApplicationCostResponse = {
|
|
58
100
|
available: false,
|
|
@@ -206,5 +248,8 @@ describe('getApplicationCostState', () => {
|
|
|
206
248
|
}),
|
|
207
249
|
).toBe('access_denied')
|
|
208
250
|
expect(getApplicationCostState(current, undefined, { trendError: true })).toBe('access_denied')
|
|
251
|
+
|
|
252
|
+
current.reason = 'configuration_mismatch'
|
|
253
|
+
expect(getApplicationCostState(current, undefined, {})).toBe('configuration_mismatch')
|
|
209
254
|
})
|
|
210
255
|
})
|