@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
|
@@ -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>
|
|
@@ -33,6 +33,7 @@ import { ConsentCard } from "./parts";
|
|
|
33
33
|
import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
|
|
34
34
|
import { type RunSummary, type ExecutionProfile } from "../../api/diagnose";
|
|
35
35
|
import { routePath } from "../../api/config";
|
|
36
|
+
import { useCapabilitiesContext } from "../../contexts/CapabilitiesContext";
|
|
36
37
|
|
|
37
38
|
function capWord(s: string): string {
|
|
38
39
|
return s ? s[0].toUpperCase() + s.slice(1) : s;
|
|
@@ -68,6 +69,7 @@ function buildConfigLine(cfg: {
|
|
|
68
69
|
function InvestigationMenu({ run }: { run: RunSummary }) {
|
|
69
70
|
const [open, setOpen] = useState(false);
|
|
70
71
|
const [copied, setCopied] = useState(false);
|
|
72
|
+
const { localTerminal } = useCapabilitiesContext();
|
|
71
73
|
const label = launchAgentLabel(run);
|
|
72
74
|
const command = buildLaunchCommand(run, `${window.location.origin}${routePath('/mcp')}`);
|
|
73
75
|
// No resumable session yet (or stale run) → nothing to hand off.
|
|
@@ -122,16 +124,18 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
|
|
|
122
124
|
)}
|
|
123
125
|
</span>
|
|
124
126
|
</button>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
{localTerminal && (
|
|
128
|
+
<button
|
|
129
|
+
onClick={() => {
|
|
130
|
+
openInTerminal(command, "Diagnose");
|
|
131
|
+
setOpen(false);
|
|
132
|
+
}}
|
|
133
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-theme-text-primary hover:bg-theme-hover"
|
|
134
|
+
>
|
|
135
|
+
<TerminalSquare className="h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
136
|
+
Run in a Radar terminal
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
135
139
|
</div>
|
|
136
140
|
</>
|
|
137
141
|
)}
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
isArgoSuspendedByRadar,
|
|
24
24
|
gitOpsInsightChangeKey,
|
|
25
25
|
initNavigationMap,
|
|
26
|
-
|
|
26
|
+
kindToPluralWithGroup,
|
|
27
27
|
normalizeArgoApplication,
|
|
28
28
|
normalizeFluxHelmRelease,
|
|
29
29
|
normalizeFluxKustomization,
|
|
@@ -458,7 +458,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
458
458
|
|
|
459
459
|
function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef) {
|
|
460
460
|
if (isGitOpsDetailRef(ref) && isValidKubernetesName(ref.name)) {
|
|
461
|
-
const detailKind =
|
|
461
|
+
const detailKind = kindToPluralWithGroup(ref.kind, ref.group ?? '')
|
|
462
462
|
// The tree's root node is this page's own subject — clicking it must not
|
|
463
463
|
// open a nested copy of the same detail page (which stacks an identical
|
|
464
464
|
// "GitOps / X / X" breadcrumb, and again, ad infinitum). A self-reference
|
|
@@ -483,7 +483,12 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
483
483
|
navigate({ pathname: gitOpsDetailPath(detailKind, ref.namespace || '_', ref.name), search: params.toString() })
|
|
484
484
|
return
|
|
485
485
|
}
|
|
486
|
-
onOpenResource({
|
|
486
|
+
onOpenResource({
|
|
487
|
+
kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
|
|
488
|
+
namespace: ref.namespace || '',
|
|
489
|
+
name: ref.name,
|
|
490
|
+
group: ref.group,
|
|
491
|
+
})
|
|
487
492
|
}
|
|
488
493
|
|
|
489
494
|
const isRunning = resourceQ.data?.status?.operationState?.phase === 'Running'
|
|
@@ -15,7 +15,7 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
|
15
15
|
import { Tooltip } from '../ui/Tooltip'
|
|
16
16
|
import { Markdown } from '../ui/Markdown'
|
|
17
17
|
import type { SelectedHelmRelease, HelmHook, ChartDependency, HelmOperation, HelmOperationInsight, HelmOwnedResource, HookDiagnostic, HookLogEvidence, UpgradeInfo, ValuesPreviewResponse } from '../../types'
|
|
18
|
-
import { apiVersionToGroup,
|
|
18
|
+
import { apiVersionToGroup, kindToPluralWithGroup, type NavigateToResource } from '../../utils/navigation'
|
|
19
19
|
import { formatDate } from './helm-utils'
|
|
20
20
|
import { getHelmStatusColor, getKindBadgeColor, getResourceStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
21
21
|
import { useCanHelmAct, useCloudRole } from '../../api/client'
|
|
@@ -1245,11 +1245,12 @@ function PrimaryOperationResourceSignal({
|
|
|
1245
1245
|
const resourceTooltip = `Open ${resource.kind} ${resourceTarget}`
|
|
1246
1246
|
const showInlineReady = Boolean(resource.ready && !resource.summary)
|
|
1247
1247
|
const openResource = () => {
|
|
1248
|
+
const group = apiVersionToGroup(resource.apiVersion)
|
|
1248
1249
|
onNavigateToResource?.({
|
|
1249
|
-
kind:
|
|
1250
|
+
kind: kindToPluralWithGroup(resource.kind, group),
|
|
1250
1251
|
namespace: resource.namespace,
|
|
1251
1252
|
name: resource.name,
|
|
1252
|
-
group
|
|
1253
|
+
group,
|
|
1253
1254
|
})
|
|
1254
1255
|
}
|
|
1255
1256
|
|
|
@@ -4,7 +4,7 @@ import { getResourceIcon } from '../../utils/resource-icons'
|
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
5
|
import type { HelmOwnedResource } from '../../types'
|
|
6
6
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
7
|
-
import {
|
|
7
|
+
import { kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
|
|
8
8
|
import { getResourceStatusColor, SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
9
9
|
import { useQueryClient } from '@tanstack/react-query'
|
|
10
10
|
import { useOpenTerminal, useOpenLogs } from '../dock'
|
|
@@ -207,7 +207,15 @@ function ResourceItem({ resource, onNavigate }: ResourceItemProps) {
|
|
|
207
207
|
|
|
208
208
|
const handleClick = () => {
|
|
209
209
|
if (onNavigate) {
|
|
210
|
-
onNavigate({
|
|
210
|
+
onNavigate({
|
|
211
|
+
kind: kindToPluralWithGroup(
|
|
212
|
+
resource.kind,
|
|
213
|
+
apiVersionToGroup(resource.apiVersion),
|
|
214
|
+
),
|
|
215
|
+
namespace: resource.namespace,
|
|
216
|
+
name: resource.name,
|
|
217
|
+
group: apiVersionToGroup(resource.apiVersion),
|
|
218
|
+
})
|
|
211
219
|
}
|
|
212
220
|
}
|
|
213
221
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { minorsBehind } from "./ClusterHealthCard";
|
|
3
|
+
|
|
4
|
+
describe("minorsBehind", () => {
|
|
5
|
+
it("counts minors between plain versions", () => {
|
|
6
|
+
expect(minorsBehind("1.33", "1.37")).toBe(4);
|
|
7
|
+
expect(minorsBehind("1.36", "1.37")).toBe(1);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns 0 when current matches or exceeds the catalog", () => {
|
|
11
|
+
expect(minorsBehind("1.37", "1.37")).toBe(0);
|
|
12
|
+
expect(minorsBehind("1.38", "1.37")).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("handles distribution version suffixes", () => {
|
|
16
|
+
expect(minorsBehind("v1.33.6+k3s1", "1.37")).toBe(4);
|
|
17
|
+
expect(minorsBehind("1.31.5-gke.1023000", "1.37")).toBe(6);
|
|
18
|
+
expect(minorsBehind("v1.32.4-eks-abc123", "1.37")).toBe(5);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("stays quiet on cross-major or unparseable input", () => {
|
|
22
|
+
expect(minorsBehind("2.0.0", "1.37")).toBe(0);
|
|
23
|
+
expect(minorsBehind("unknown", "1.37")).toBe(0);
|
|
24
|
+
expect(minorsBehind("", "1.37")).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("stays quiet when the catalog version is missing", () => {
|
|
28
|
+
expect(minorsBehind("1.33", undefined)).toBe(0);
|
|
29
|
+
expect(minorsBehind("1.33", "")).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -13,6 +13,7 @@ import { MCPSetupDialog } from './MCPSetupDialog'
|
|
|
13
13
|
import { assetUrl, pluralize, parseContextName } from '@skyhook-io/k8s-ui'
|
|
14
14
|
import { Tooltip } from '../ui/Tooltip'
|
|
15
15
|
import { routePath } from '../../api/config'
|
|
16
|
+
import { parseMajorMinor } from '../../utils/version'
|
|
16
17
|
import gkeIcon from '../../assets/platform-icons/google_kubernetes_engine.png'
|
|
17
18
|
import eksIcon from '../../assets/platform-icons/aws_eks.png'
|
|
18
19
|
import aksIcon from '../../assets/platform-icons/azure-aks.svg'
|
|
@@ -33,11 +34,14 @@ interface ClusterHealthCardProps {
|
|
|
33
34
|
nodeVersionSkew: DashboardResponse['nodeVersionSkew']
|
|
34
35
|
onNavigateToKind: (kind: string, group?: string) => void
|
|
35
36
|
onNavigateToView: () => void
|
|
37
|
+
// Left unset in Cloud takeover mode, where /checks is owned by the host.
|
|
38
|
+
onNavigateToUpgradeImpact?: () => void
|
|
36
39
|
onWarningEventsClick?: () => void
|
|
37
40
|
onIssuesClick?: () => void
|
|
38
41
|
// Freshness/refresh control for the dashboard poll — rendered under the
|
|
39
42
|
// cluster metadata so the overview carries a freshness signal without a band.
|
|
40
43
|
freshness?: ReactNode
|
|
44
|
+
radarVersion?: ReactNode
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
function getMetricsInstallHint(platform: string): string {
|
|
@@ -130,9 +134,11 @@ export function ClusterHealthCard({
|
|
|
130
134
|
nodeVersionSkew,
|
|
131
135
|
onNavigateToKind,
|
|
132
136
|
onNavigateToView,
|
|
137
|
+
onNavigateToUpgradeImpact,
|
|
133
138
|
onWarningEventsClick,
|
|
134
139
|
onIssuesClick,
|
|
135
140
|
freshness,
|
|
141
|
+
radarVersion,
|
|
136
142
|
}: ClusterHealthCardProps) {
|
|
137
143
|
void _topCRDs // Reserved for future CRD display
|
|
138
144
|
|
|
@@ -251,8 +257,13 @@ export function ClusterHealthCard({
|
|
|
251
257
|
</Tooltip>
|
|
252
258
|
)}
|
|
253
259
|
{cluster.version && (
|
|
254
|
-
<
|
|
260
|
+
<KubernetesVersionLine
|
|
261
|
+
version={cluster.version}
|
|
262
|
+
reviewedThrough={cluster.upgradeReviewedThrough}
|
|
263
|
+
onNavigate={onNavigateToUpgradeImpact}
|
|
264
|
+
/>
|
|
255
265
|
)}
|
|
266
|
+
{radarVersion}
|
|
256
267
|
<span><span className="font-mono">{counts.namespaces}</span> namespaces</span>
|
|
257
268
|
{/* Show raw kubeconfig context as muted metadata only when
|
|
258
269
|
it differs from the headline AND we're in local mode
|
|
@@ -519,6 +530,54 @@ export function ClusterHealthCard({
|
|
|
519
530
|
)
|
|
520
531
|
}
|
|
521
532
|
|
|
533
|
+
// How many minors the running version trails the newest one the upgrade-impact
|
|
534
|
+
// catalog covers. Cross-major comparisons return 0 — better a missing hint than
|
|
535
|
+
// a wrong count on an exotic version string.
|
|
536
|
+
export function minorsBehind(current: string, reviewedThrough?: string): number {
|
|
537
|
+
const cur = parseMajorMinor(current)
|
|
538
|
+
const latest = reviewedThrough ? parseMajorMinor(reviewedThrough) : null
|
|
539
|
+
if (!cur || !latest || latest.major !== cur.major) return 0
|
|
540
|
+
return Math.max(0, latest.minor - cur.minor)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function KubernetesVersionLine({
|
|
544
|
+
version,
|
|
545
|
+
reviewedThrough,
|
|
546
|
+
onNavigate,
|
|
547
|
+
}: {
|
|
548
|
+
version: string
|
|
549
|
+
reviewedThrough?: string
|
|
550
|
+
onNavigate?: () => void
|
|
551
|
+
}) {
|
|
552
|
+
if (!onNavigate) {
|
|
553
|
+
return <span>Kubernetes {version}</span>
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const behind = minorsBehind(version, reviewedThrough)
|
|
557
|
+
|
|
558
|
+
return (
|
|
559
|
+
<Tooltip
|
|
560
|
+
content={
|
|
561
|
+
behind > 0
|
|
562
|
+
? `Radar's upgrade checks cover Kubernetes through ${reviewedThrough}. Click to assess the next minor upgrade.`
|
|
563
|
+
: 'Assess the next minor Kubernetes upgrade.'
|
|
564
|
+
}
|
|
565
|
+
wrapperClassName="w-fit"
|
|
566
|
+
>
|
|
567
|
+
<button
|
|
568
|
+
onClick={onNavigate}
|
|
569
|
+
className="group flex items-center gap-1 hover:text-theme-text-secondary transition-colors"
|
|
570
|
+
>
|
|
571
|
+
<span>Kubernetes {version}</span>
|
|
572
|
+
{behind > 0 && (
|
|
573
|
+
<span>· Upgrade impact</span>
|
|
574
|
+
)}
|
|
575
|
+
<ArrowRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
576
|
+
</button>
|
|
577
|
+
</Tooltip>
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
522
581
|
function RestrictedRing({ label }: { label: string }) {
|
|
523
582
|
const radius = 36
|
|
524
583
|
const circumference = 2 * Math.PI * radius
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo, type ReactNode } from 'react'
|
|
2
|
-
import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
|
|
2
|
+
import { useCloudConnectSelf, useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, useVersionCheck, type IssuesResponse } from '../../api/client'
|
|
3
3
|
import { useConnection } from '../../context/ConnectionContext'
|
|
4
4
|
import type { ClusterLoadState } from '../../types/clusterLoadState'
|
|
5
5
|
import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
|
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
StatusDot,
|
|
23
23
|
categoryLabel,
|
|
24
24
|
groupLabel,
|
|
25
|
-
|
|
25
|
+
issueFirstSeenTitle,
|
|
26
|
+
issueTimingForDisplay,
|
|
26
27
|
subjectRef,
|
|
27
28
|
type Issue,
|
|
28
29
|
} from '@skyhook-io/k8s-ui'
|
|
@@ -30,6 +31,8 @@ import { formatCompactAge } from '@skyhook-io/k8s-ui/utils/format'
|
|
|
30
31
|
import { ClusterHealthCard } from './ClusterHealthCard'
|
|
31
32
|
import { AlertTriangle, CheckCircle, Loader2, Shield } from 'lucide-react'
|
|
32
33
|
import { clsx } from 'clsx'
|
|
34
|
+
import { getVersionUpdateStatus } from '../../utils/version'
|
|
35
|
+
import { RadarVersionLine } from './RadarVersionLine'
|
|
33
36
|
|
|
34
37
|
interface HomeViewProps {
|
|
35
38
|
namespaces: string[]
|
|
@@ -45,14 +48,19 @@ interface HomeViewProps {
|
|
|
45
48
|
* standalone OSS → the card drills into secrets as before.
|
|
46
49
|
*/
|
|
47
50
|
onNavigateToCerts?: () => void
|
|
51
|
+
// Omitted when an embedded host owns /checks, leaving the version as plain text.
|
|
52
|
+
onNavigateToUpgradeImpact?: () => void
|
|
53
|
+
onNavigateToHelmRelease?: (namespace: string, release: string) => void
|
|
54
|
+
onNavigateToManagerPath?: (path: string) => void
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
|
|
57
|
+
export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts, onNavigateToUpgradeImpact, onNavigateToHelmRelease, onNavigateToManagerPath }: HomeViewProps) {
|
|
51
58
|
// The card itself decides whether the cluster has a capacity story
|
|
52
59
|
// (available, softened-denied, or karpenterless-with-managers/groups) and
|
|
53
60
|
// returns null otherwise — the outer gate only excludes states with nothing
|
|
54
61
|
// to fetch against.
|
|
55
|
-
const
|
|
62
|
+
const capabilities = useCapabilitiesContext()
|
|
63
|
+
const karpenterState = capabilities.karpenter?.state
|
|
56
64
|
const capacityCardPossible =
|
|
57
65
|
karpenterState === 'available' || karpenterState === 'denied' || karpenterState === 'not_detected'
|
|
58
66
|
const { data, isLoading, error, dataUpdatedAt, refetch } = useDashboard(namespaces)
|
|
@@ -61,6 +69,12 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
|
|
|
61
69
|
const issues = issuesData?.issues ?? []
|
|
62
70
|
const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
|
|
63
71
|
const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
|
|
72
|
+
const deploymentMode = capabilities.deployment?.mode ?? 'local'
|
|
73
|
+
const { data: versionInfo } = useVersionCheck()
|
|
74
|
+
const showHomeUpgrade = deploymentMode === 'in-cluster'
|
|
75
|
+
&& !!versionInfo?.updateAvailable
|
|
76
|
+
&& getVersionUpdateStatus(versionInfo.currentVersion, versionInfo.latestVersion).tier !== 'none'
|
|
77
|
+
const { data: installationManager, isLoading: installationManagerLoading } = useCloudConnectSelf(showHomeUpgrade)
|
|
64
78
|
|
|
65
79
|
// SSE is cluster-wide on small/medium clusters; the picker only narrows the
|
|
66
80
|
// dashboard summary, so re-apply the filter here or the legend disagrees.
|
|
@@ -124,6 +138,15 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
|
|
|
124
138
|
)}
|
|
125
139
|
{/* Row 1: Cluster Health Card (combined health + resource counts) */}
|
|
126
140
|
<ClusterHealthCard
|
|
141
|
+
radarVersion={deploymentMode === 'in-cluster' && versionInfo ? (
|
|
142
|
+
<RadarVersionLine
|
|
143
|
+
version={versionInfo}
|
|
144
|
+
manager={installationManager}
|
|
145
|
+
managerLoading={installationManagerLoading}
|
|
146
|
+
onNavigateToHelmRelease={onNavigateToHelmRelease}
|
|
147
|
+
onNavigateToGitOps={onNavigateToManagerPath}
|
|
148
|
+
/>
|
|
149
|
+
) : undefined}
|
|
127
150
|
freshness={
|
|
128
151
|
<FreshnessControl
|
|
129
152
|
mode="auto"
|
|
@@ -143,6 +166,7 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
|
|
|
143
166
|
nodeVersionSkew={data.nodeVersionSkew}
|
|
144
167
|
onNavigateToKind={onNavigateToResourceKind}
|
|
145
168
|
onNavigateToView={() => onNavigateToView('resources')}
|
|
169
|
+
onNavigateToUpgradeImpact={onNavigateToUpgradeImpact}
|
|
146
170
|
onWarningEventsClick={() => onNavigateToView('timeline', { view: 'list', filter: 'warnings', time: 'all' })}
|
|
147
171
|
onIssuesClick={() => onNavigateToView('issues')}
|
|
148
172
|
/>
|
|
@@ -383,8 +407,10 @@ function ProblemsPanel({
|
|
|
383
407
|
<div className="divide-y divide-theme-border">
|
|
384
408
|
{issues.map((issue) => {
|
|
385
409
|
const ref = subjectRef(issue)
|
|
386
|
-
const
|
|
387
|
-
const
|
|
410
|
+
const partialUnknown = issue.onset_coverage?.unknown ?? 0
|
|
411
|
+
const partialOnset = Boolean(issue.first_seen && partialUnknown > 0)
|
|
412
|
+
const age = issue.first_seen ? `${partialOnset ? '≥' : ''}${formatCompactAge(issue.first_seen)}` : ''
|
|
413
|
+
const timing = issueTimingForDisplay(issue)
|
|
388
414
|
|
|
389
415
|
return (
|
|
390
416
|
<button
|
|
@@ -402,12 +428,11 @@ function ProblemsPanel({
|
|
|
402
428
|
<div className="flex items-center gap-1.5">
|
|
403
429
|
<span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
|
|
404
430
|
<span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
|
|
405
|
-
{(age || timing
|
|
431
|
+
{(age || timing) && (
|
|
406
432
|
<span className="ml-auto flex shrink-0 items-center gap-1">
|
|
407
|
-
{age &&
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
<span className="text-[10px] text-theme-text-tertiary">Onset unknown</span>
|
|
433
|
+
{age && (
|
|
434
|
+
<Tooltip content={issueFirstSeenTitle(issue)} delay={100}>
|
|
435
|
+
<span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>
|
|
411
436
|
</Tooltip>
|
|
412
437
|
)}
|
|
413
438
|
{timing && (
|
|
@@ -204,16 +204,17 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
|
|
|
204
204
|
<a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:text-purple-300 underline underline-offset-2">
|
|
205
205
|
Model Context Protocol
|
|
206
206
|
</a>{' '}
|
|
207
|
-
(MCP) server that lets AI agents
|
|
207
|
+
(MCP) server that lets AI agents inspect, diagnose, and operate your cluster through Radar.
|
|
208
208
|
Unlike raw kubectl access, Radar gives your AI pre-processed, enriched data —
|
|
209
209
|
topology graphs, health assessments, deduplicated events, filtered logs — so it
|
|
210
210
|
can understand your cluster state quickly without burning through context on
|
|
211
211
|
verbose YAML output.
|
|
212
212
|
</p>
|
|
213
213
|
<p className="text-sm text-theme-text-secondary leading-relaxed">
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
Most read tools do not change cluster state. Live route diagnosis can create up
|
|
215
|
+
to five self-deleting probe pods when explicitly requested. Write tools (restart,
|
|
216
|
+
scale, sync, apply, node drain) are annotated as destructive so your AI client
|
|
217
|
+
can flag them and prompt before running.
|
|
217
218
|
</p>
|
|
218
219
|
</div>
|
|
219
220
|
|