@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,7 +1,8 @@
|
|
|
1
1
|
import type { OpenCostSummary } from '../../api/client'
|
|
2
2
|
import { useOpenCostSummary } from '../../api/client'
|
|
3
|
-
import {
|
|
3
|
+
import { Coins } from 'lucide-react'
|
|
4
4
|
import {
|
|
5
|
+
DEFAULT_COST_CURRENCY,
|
|
5
6
|
formatCostPerHour,
|
|
6
7
|
formatProjectedDailyRate,
|
|
7
8
|
formatProjectedMonthlyCost,
|
|
@@ -21,6 +22,7 @@ export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
21
22
|
|
|
22
23
|
function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNavigate?: () => void }) {
|
|
23
24
|
const hourlyCost = data.totalHourlyCost ?? 0
|
|
25
|
+
const currency = data.currency ?? DEFAULT_COST_CURRENCY
|
|
24
26
|
const namespaces = data.namespaces ?? []
|
|
25
27
|
const topNamespaces = namespaces.slice(0, 5)
|
|
26
28
|
|
|
@@ -35,7 +37,7 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
35
37
|
<div className="flex flex-col h-full w-full">
|
|
36
38
|
<div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
|
|
37
39
|
<div className="flex items-center gap-2">
|
|
38
|
-
<
|
|
40
|
+
<Coins className="w-4 h-4 text-accent-text" />
|
|
39
41
|
<span className="text-xs font-semibold uppercase tracking-wider text-accent-text">Cost Insights</span>
|
|
40
42
|
{namespaces.length > 0 && (
|
|
41
43
|
<span className="badge-sm border border-theme-border bg-accent-muted text-accent-text">
|
|
@@ -50,14 +52,14 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
50
52
|
<div className="flex items-baseline gap-3 mb-3">
|
|
51
53
|
<div className="flex items-baseline gap-1">
|
|
52
54
|
<span className="text-2xl font-bold text-theme-text-primary tabular-nums">
|
|
53
|
-
{formatProjectedMonthlyCost(hourlyCost)}
|
|
55
|
+
{formatProjectedMonthlyCost(hourlyCost, currency)}
|
|
54
56
|
</span>
|
|
55
57
|
<span className="text-xs text-theme-text-tertiary">/mo</span>
|
|
56
58
|
</div>
|
|
57
59
|
<div className="flex items-baseline gap-1.5 text-theme-text-secondary">
|
|
58
|
-
<span className="text-xs font-medium tabular-nums">{formatProjectedDailyRate(hourlyCost)}</span>
|
|
60
|
+
<span className="text-xs font-medium tabular-nums">{formatProjectedDailyRate(hourlyCost, currency)}</span>
|
|
59
61
|
<span className="text-[10px] text-theme-text-quaternary">·</span>
|
|
60
|
-
<span className="text-xs font-medium tabular-nums">{formatCostPerHour(hourlyCost)}</span>
|
|
62
|
+
<span className="text-xs font-medium tabular-nums">{formatCostPerHour(hourlyCost, currency)}</span>
|
|
61
63
|
</div>
|
|
62
64
|
</div>
|
|
63
65
|
|
|
@@ -72,7 +74,7 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
72
74
|
<div className="h-full rounded-full bg-indigo-500/60" style={{ width: `${Math.max(pct, 2)}%` }} />
|
|
73
75
|
</div>
|
|
74
76
|
<span className="text-[10px] text-theme-text-tertiary tabular-nums w-20 text-right shrink-0">
|
|
75
|
-
{formatProjectedMonthlyRate(ns.hourlyCost)}
|
|
77
|
+
{formatProjectedMonthlyRate(ns.hourlyCost, currency)}
|
|
76
78
|
</span>
|
|
77
79
|
</div>
|
|
78
80
|
)
|
|
@@ -85,7 +87,10 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
85
87
|
|
|
86
88
|
<div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-between">
|
|
87
89
|
<span className="text-[10px] text-theme-text-tertiary">
|
|
88
|
-
{
|
|
90
|
+
{currency} · projected monthly from {data.window ?? '1h'} window
|
|
91
|
+
{currency !== DEFAULT_COST_CURRENCY && (
|
|
92
|
+
<> · no conversion</>
|
|
93
|
+
)}
|
|
89
94
|
</span>
|
|
90
95
|
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-accent-text">
|
|
91
96
|
OpenCost
|
|
@@ -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
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import type { VersionInfo } from '../../api/client'
|
|
4
|
+
import { RadarVersionLine } from './RadarVersionLine'
|
|
5
|
+
|
|
6
|
+
const version: VersionInfo = {
|
|
7
|
+
currentVersion: '1.2.3',
|
|
8
|
+
latestVersion: '1.3.0',
|
|
9
|
+
updateAvailable: true,
|
|
10
|
+
installMethod: 'direct',
|
|
11
|
+
releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.3.0',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('RadarVersionLine', () => {
|
|
15
|
+
it('shows the running version without an upgrade affordance when up to date', () => {
|
|
16
|
+
const html = renderToString(
|
|
17
|
+
<RadarVersionLine version={{ ...version, latestVersion: '1.2.3', updateAvailable: false }} />,
|
|
18
|
+
)
|
|
19
|
+
expect(html).toContain('Radar')
|
|
20
|
+
expect(html).toContain('v1.2.3')
|
|
21
|
+
expect(html).not.toContain('available')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('shows patch upgrades with the quiet treatment', () => {
|
|
25
|
+
const html = renderToString(
|
|
26
|
+
<RadarVersionLine version={{ ...version, latestVersion: '1.2.4' }} />,
|
|
27
|
+
)
|
|
28
|
+
expect(html).toContain('v1.2.3')
|
|
29
|
+
expect(html).toContain('v1.2.4')
|
|
30
|
+
expect(html).toContain('available')
|
|
31
|
+
expect(html).toContain('text-accent-text hover:text-accent')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('makes minor upgrades more prominent', () => {
|
|
35
|
+
const html = renderToString(<RadarVersionLine version={version} />)
|
|
36
|
+
expect(html).toContain('font-medium text-accent hover:text-accent-light')
|
|
37
|
+
expect(html).not.toContain('minor releases behind')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses warning emphasis and explains when an installation is three minor releases behind', () => {
|
|
41
|
+
const html = renderToString(
|
|
42
|
+
<RadarVersionLine version={{ ...version, currentVersion: '1.0.9', latestVersion: '1.3.0' }} />,
|
|
43
|
+
)
|
|
44
|
+
expect(html).toContain('font-medium text-warning-text hover:opacity-80')
|
|
45
|
+
expect(html).toContain('This installation is 3 minor releases behind')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('uses warning emphasis for a major upgrade', () => {
|
|
49
|
+
const html = renderToString(
|
|
50
|
+
<RadarVersionLine version={{ ...version, currentVersion: '0.12.0', latestVersion: '1.3.0' }} />,
|
|
51
|
+
)
|
|
52
|
+
expect(html).toContain('font-medium text-warning-text hover:opacity-80')
|
|
53
|
+
expect(html).toContain('A major Radar upgrade is available')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not claim manager discovery has failed while it is loading', () => {
|
|
57
|
+
const html = renderToString(<RadarVersionLine version={version} managerLoading />)
|
|
58
|
+
expect(html).toContain('v1.3.0')
|
|
59
|
+
expect(html).toContain('available')
|
|
60
|
+
expect(html).toContain('lucide-circle-arrow-up')
|
|
61
|
+
expect(html).toContain('Checking how this installation is managed')
|
|
62
|
+
expect(html).not.toContain('could not be confirmed')
|
|
63
|
+
expect(html).not.toContain('<a')
|
|
64
|
+
expect(html).not.toContain('<button')
|
|
65
|
+
expect(html).toContain('class="sr-only"')
|
|
66
|
+
expect(html).not.toContain('aria-label=')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('opens actionable upgrade instructions when the installation manager is unknown', () => {
|
|
70
|
+
const html = renderToString(<RadarVersionLine version={version} />)
|
|
71
|
+
expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
|
|
72
|
+
expect(html).not.toContain('#upgrading')
|
|
73
|
+
expect(html).toContain('Open the in-cluster upgrade instructions')
|
|
74
|
+
expect(html).not.toContain(version.releaseUrl)
|
|
75
|
+
expect(html).toContain('font-medium text-accent hover:text-accent-light')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps the visible upgrade label in the accessible name', () => {
|
|
79
|
+
const html = renderToString(<RadarVersionLine version={version} />)
|
|
80
|
+
expect(html).toContain('aria-label="v1.3.0 available —')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('deep-links exact Helm ownership when the host supports it', () => {
|
|
84
|
+
const html = renderToString(
|
|
85
|
+
<RadarVersionLine
|
|
86
|
+
version={version}
|
|
87
|
+
manager={{ ownership: 'helm', namespace: 'radar-system', release: 'radar' }}
|
|
88
|
+
onNavigateToHelmRelease={() => {}}
|
|
89
|
+
/>,
|
|
90
|
+
)
|
|
91
|
+
expect(html).toContain('Managed by Helm release radar-system/radar')
|
|
92
|
+
expect(html).toContain('Open the release to upgrade')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('describes the docs fallback when a Helm navigation callback is unavailable', () => {
|
|
96
|
+
const html = renderToString(
|
|
97
|
+
<RadarVersionLine
|
|
98
|
+
version={version}
|
|
99
|
+
manager={{ ownership: 'helm', namespace: 'radar-system', release: 'radar' }}
|
|
100
|
+
/>,
|
|
101
|
+
)
|
|
102
|
+
expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
|
|
103
|
+
expect(html).toContain('Managed by Helm release radar-system/radar')
|
|
104
|
+
expect(html).toContain('Open the in-cluster upgrade instructions')
|
|
105
|
+
expect(html).not.toContain('Open the release to upgrade')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('deep-links verified GitOps ownership', () => {
|
|
109
|
+
const controllerRef = { group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', namespace: 'flux-system', name: 'radar' }
|
|
110
|
+
const verified = renderToString(
|
|
111
|
+
<RadarVersionLine
|
|
112
|
+
version={version}
|
|
113
|
+
manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar', controllerRef }}
|
|
114
|
+
onNavigateToGitOps={() => {}}
|
|
115
|
+
/>,
|
|
116
|
+
)
|
|
117
|
+
expect(verified).toContain('Managed by Kustomization flux-system/radar')
|
|
118
|
+
expect(verified).toContain('Open it to upgrade through GitOps')
|
|
119
|
+
|
|
120
|
+
const suspected = renderToString(
|
|
121
|
+
<RadarVersionLine
|
|
122
|
+
version={version}
|
|
123
|
+
manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar' }}
|
|
124
|
+
onNavigateToGitOps={() => {}}
|
|
125
|
+
/>,
|
|
126
|
+
)
|
|
127
|
+
expect(suspected).toContain('appears to be managed through GitOps (Kustomization flux-system/radar)')
|
|
128
|
+
expect(suspected).toContain('Open the upgrade instructions')
|
|
129
|
+
expect(suspected).not.toContain('Managed by Kustomization')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('describes the docs fallback when a GitOps navigation callback is unavailable', () => {
|
|
133
|
+
const controllerRef = { group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', namespace: 'flux-system', name: 'radar' }
|
|
134
|
+
const html = renderToString(
|
|
135
|
+
<RadarVersionLine
|
|
136
|
+
version={version}
|
|
137
|
+
manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar', controllerRef }}
|
|
138
|
+
/>,
|
|
139
|
+
)
|
|
140
|
+
expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
|
|
141
|
+
expect(html).toContain('Managed by Kustomization flux-system/radar')
|
|
142
|
+
expect(html).toContain('Open the in-cluster upgrade instructions and apply the change through GitOps')
|
|
143
|
+
expect(html).not.toContain('Open it to upgrade through GitOps')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { ArrowUpCircle } from 'lucide-react'
|
|
2
|
+
import { gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import type { CloudConnectSelf, VersionInfo } from '../../api/client'
|
|
4
|
+
import {
|
|
5
|
+
getVersionUpdateStatus,
|
|
6
|
+
IN_CLUSTER_UPGRADE_URL,
|
|
7
|
+
type VersionUpdateTier,
|
|
8
|
+
} from '../../utils/version'
|
|
9
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
10
|
+
|
|
11
|
+
interface RadarVersionLineProps {
|
|
12
|
+
version: VersionInfo
|
|
13
|
+
manager?: CloudConnectSelf
|
|
14
|
+
managerLoading?: boolean
|
|
15
|
+
onNavigateToHelmRelease?: (namespace: string, release: string) => void
|
|
16
|
+
onNavigateToGitOps?: (path: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function displayVersion(version: string): string {
|
|
20
|
+
return version === 'dev' || version.startsWith('v') ? version : `v${version}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function RadarVersionLine({
|
|
24
|
+
version,
|
|
25
|
+
manager,
|
|
26
|
+
managerLoading = false,
|
|
27
|
+
onNavigateToHelmRelease,
|
|
28
|
+
onNavigateToGitOps,
|
|
29
|
+
}: RadarVersionLineProps) {
|
|
30
|
+
const latestVersion = version.latestVersion
|
|
31
|
+
const updateStatus = getVersionUpdateStatus(version.currentVersion, latestVersion)
|
|
32
|
+
const showUpgrade = version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
|
|
33
|
+
|
|
34
|
+
if (!showUpgrade) {
|
|
35
|
+
return <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const controller = manager?.controllerRef
|
|
39
|
+
const gitOpsPath = controller
|
|
40
|
+
? gitOpsRouteForResource({
|
|
41
|
+
apiVersion: controller.group ? `${controller.group}/v1` : undefined,
|
|
42
|
+
kind: controller.kind,
|
|
43
|
+
metadata: { namespace: controller.namespace, name: controller.name },
|
|
44
|
+
})
|
|
45
|
+
: null
|
|
46
|
+
|
|
47
|
+
let detail = managerLoading
|
|
48
|
+
? 'Checking how this installation is managed.'
|
|
49
|
+
: 'The installation manager could not be confirmed. Open the in-cluster upgrade instructions.'
|
|
50
|
+
let onClick: (() => void) | undefined
|
|
51
|
+
const actionClassName = `inline-flex items-center gap-1 transition-colors ${upgradeActionClassName(updateStatus.tier)}`
|
|
52
|
+
|
|
53
|
+
if (manager?.ownership === 'helm' && manager.namespace && manager.release) {
|
|
54
|
+
if (onNavigateToHelmRelease) {
|
|
55
|
+
detail = `Managed by Helm release ${manager.namespace}/${manager.release}. Open the release to upgrade.`
|
|
56
|
+
onClick = () => onNavigateToHelmRelease(manager.namespace!, manager.release!)
|
|
57
|
+
} else {
|
|
58
|
+
detail = `Managed by Helm release ${manager.namespace}/${manager.release}. Open the in-cluster upgrade instructions.`
|
|
59
|
+
}
|
|
60
|
+
} else if (controller && gitOpsPath) {
|
|
61
|
+
const objectName = `${controller.namespace ? `${controller.namespace}/` : ''}${controller.name}`
|
|
62
|
+
if (onNavigateToGitOps) {
|
|
63
|
+
detail = `Managed by ${controller.kind} ${objectName}. Open it to upgrade through GitOps.`
|
|
64
|
+
onClick = () => onNavigateToGitOps(gitOpsPath)
|
|
65
|
+
} else {
|
|
66
|
+
detail = `Managed by ${controller.kind} ${objectName}. Open the in-cluster upgrade instructions and apply the change through GitOps.`
|
|
67
|
+
}
|
|
68
|
+
} else if (manager?.ownership === 'gitops') {
|
|
69
|
+
detail = manager.controller
|
|
70
|
+
? `This installation appears to be managed through GitOps (${manager.controller}). Open the upgrade instructions and apply the change through its source of truth.`
|
|
71
|
+
: 'This installation appears to be managed through GitOps. Open the upgrade instructions and apply the change through its source of truth.'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ageDetail = updateAgeDetail(updateStatus)
|
|
75
|
+
const accessibleLabel = `${displayVersion(latestVersion)} available${ageDetail ? `. ${ageDetail}` : ''} — ${detail}`
|
|
76
|
+
const action = managerLoading ? (
|
|
77
|
+
<span className={actionClassName}>
|
|
78
|
+
<UpgradeLabel version={latestVersion} />
|
|
79
|
+
<span className="sr-only">{detail}</span>
|
|
80
|
+
</span>
|
|
81
|
+
) : onClick ? (
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className={actionClassName}
|
|
85
|
+
onClick={onClick}
|
|
86
|
+
aria-label={accessibleLabel}
|
|
87
|
+
>
|
|
88
|
+
<UpgradeLabel version={latestVersion} />
|
|
89
|
+
</button>
|
|
90
|
+
) : (
|
|
91
|
+
<a
|
|
92
|
+
href={IN_CLUSTER_UPGRADE_URL}
|
|
93
|
+
target="_blank"
|
|
94
|
+
rel="noreferrer"
|
|
95
|
+
className={actionClassName}
|
|
96
|
+
aria-label={accessibleLabel}
|
|
97
|
+
>
|
|
98
|
+
<UpgradeLabel version={latestVersion} />
|
|
99
|
+
</a>
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<span className="inline-flex flex-wrap items-center gap-x-1">
|
|
104
|
+
<span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
|
|
105
|
+
<span className="inline-flex items-center gap-1">
|
|
106
|
+
<span aria-hidden>·</span>
|
|
107
|
+
<Tooltip
|
|
108
|
+
content={accessibleLabel}
|
|
109
|
+
className="!whitespace-normal !max-w-sm"
|
|
110
|
+
>
|
|
111
|
+
{action}
|
|
112
|
+
</Tooltip>
|
|
113
|
+
</span>
|
|
114
|
+
</span>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function upgradeActionClassName(tier: VersionUpdateTier): string {
|
|
119
|
+
if (tier === 'patch') return 'text-accent-text hover:text-accent'
|
|
120
|
+
if (tier === 'minor') return 'font-medium text-accent hover:text-accent-light'
|
|
121
|
+
return 'font-medium text-warning-text hover:opacity-80'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function updateAgeDetail(status: ReturnType<typeof getVersionUpdateStatus>): string | undefined {
|
|
125
|
+
if (status.majorVersionBehind) return 'A major Radar upgrade is available.'
|
|
126
|
+
if (status.tier !== 'stale' || !status.minorVersionsBehind) return undefined
|
|
127
|
+
return `This installation is ${status.minorVersionsBehind} minor releases behind.`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function UpgradeLabel({ version }: { version: string }) {
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<ArrowUpCircle className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
134
|
+
<span><span className="font-mono">{displayVersion(version)}</span> available</span>
|
|
135
|
+
</>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -147,6 +147,18 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
147
147
|
{ arg: 'limit', desc: 'max findings (default 30, max 100)' },
|
|
148
148
|
],
|
|
149
149
|
},
|
|
150
|
+
{
|
|
151
|
+
name: 'get_cluster_upgrade_readiness',
|
|
152
|
+
desc: 'Upgrade impact analysis for a target Kubernetes minor: the evidenced check catalog (version skew, removed/deprecated APIs, node runtime, drain feasibility, webhook readiness) with per-check coverage and caveats, expandable into findings with evidence and remediation. The first call runs a live scan; results are briefly cached per caller.',
|
|
153
|
+
params: [
|
|
154
|
+
{ arg: 'target', desc: 'target Kubernetes minor (default: next minor above current)' },
|
|
155
|
+
{ arg: 'check', desc: 'check id to expand into findings' },
|
|
156
|
+
{ arg: 'level', desc: 'filter expanded findings: blocker, warning, or review' },
|
|
157
|
+
{ arg: 'offset', desc: 'page through findings beyond the per-call cap' },
|
|
158
|
+
{ arg: 'scan_id', desc: 'binds paging to one scan snapshot (required with offset)' },
|
|
159
|
+
{ arg: 'refresh', desc: 'bypass the cached scan after changing something' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
150
162
|
{
|
|
151
163
|
name: 'list_helm_releases',
|
|
152
164
|
desc: 'All Helm releases with status, resource health, storage namespace, Flux ownership, current lastOperation, and capped operation trails for failed upgrades, rollbacks, or stuck pending operations.',
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
GitBranch,
|
|
11
11
|
Boxes,
|
|
12
12
|
Activity,
|
|
13
|
-
|
|
13
|
+
Coins,
|
|
14
14
|
Gauge,
|
|
15
15
|
ShieldCheck,
|
|
16
16
|
Settings,
|
|
@@ -80,7 +80,7 @@ const NAV_ITEMS: NavItemDef[] = [
|
|
|
80
80
|
{ view: "gitops", icon: GitBranch, label: "GitOps" },
|
|
81
81
|
{ view: "checks", icon: ShieldCheck, label: "Checks" },
|
|
82
82
|
{ view: "capacity", icon: Gauge, label: "Capacity" },
|
|
83
|
-
{ view: "cost", icon:
|
|
83
|
+
{ view: "cost", icon: Coins, label: "Cost" },
|
|
84
84
|
];
|
|
85
85
|
|
|
86
86
|
interface PrimaryNavRailProps {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
|
1
|
+
import { createElement, useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { X, File, Link2, ChevronRight, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
|
|
4
4
|
import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
|
|
@@ -7,6 +7,9 @@ import type { FileNode } from '../../types'
|
|
|
7
7
|
import { formatBytes } from '../../utils/format'
|
|
8
8
|
import { downloadBlob, filterTree } from './file-browser-utils'
|
|
9
9
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
10
|
+
import { isDesktopApp } from '../../utils/desktop-download'
|
|
11
|
+
import { openFile, openFolder } from '../../utils/desktop-open-folder'
|
|
12
|
+
import { useToast } from '../ui/Toast'
|
|
10
13
|
import { Tooltip } from '../ui/Tooltip'
|
|
11
14
|
|
|
12
15
|
interface PodFilesystem {
|
|
@@ -35,6 +38,36 @@ async function fetchPodFiles(
|
|
|
35
38
|
return response.json()
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Desktop only: has the backend write the pod file straight to disk. The browser
|
|
43
|
+
* route would hand the whole file to the webview only to have it hand every byte
|
|
44
|
+
* back to be saved, which is what puts a large file out of reach there.
|
|
45
|
+
* Returns the path it was saved to.
|
|
46
|
+
*/
|
|
47
|
+
async function savePodFileToDisk(
|
|
48
|
+
namespace: string,
|
|
49
|
+
podName: string,
|
|
50
|
+
container: string,
|
|
51
|
+
filePath: string,
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
const params = new URLSearchParams()
|
|
54
|
+
params.set('container', container)
|
|
55
|
+
params.set('path', filePath)
|
|
56
|
+
|
|
57
|
+
const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/files/save?${params.toString()}`), {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
credentials: getCredentialsMode(),
|
|
60
|
+
headers: getAuthHeaders(),
|
|
61
|
+
})
|
|
62
|
+
if (response.status === 204) throw new Error('cancelled')
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const error = await response.json().catch(() => ({ error: 'Save failed' }))
|
|
65
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
66
|
+
}
|
|
67
|
+
const body = await response.json()
|
|
68
|
+
return body.path
|
|
69
|
+
}
|
|
70
|
+
|
|
38
71
|
interface PodFilesystemModalProps {
|
|
39
72
|
open: boolean
|
|
40
73
|
onClose: () => void
|
|
@@ -309,6 +342,7 @@ interface PodFileTreeNodeProps {
|
|
|
309
342
|
|
|
310
343
|
function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: PodFileTreeNodeProps) {
|
|
311
344
|
const [downloading, setDownloading] = useState(false)
|
|
345
|
+
const { showSuccess, showError } = useToast()
|
|
312
346
|
const isDir = node.type === 'dir'
|
|
313
347
|
const isSymlink = node.type === 'symlink'
|
|
314
348
|
const isDownloadable = !isDir // files and symlinks can be downloaded
|
|
@@ -319,6 +353,21 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
|
|
|
319
353
|
|
|
320
354
|
setDownloading(true)
|
|
321
355
|
try {
|
|
356
|
+
if (await isDesktopApp()) {
|
|
357
|
+
const savedPath = await savePodFileToDisk(namespace, podName, container, node.path)
|
|
358
|
+
showSuccess(
|
|
359
|
+
'File saved',
|
|
360
|
+
savedPath,
|
|
361
|
+
{
|
|
362
|
+
label: 'Show in Finder',
|
|
363
|
+
icon: createElement(FolderOpen, { className: 'w-3.5 h-3.5' }),
|
|
364
|
+
onClick: () => openFolder(savedPath),
|
|
365
|
+
},
|
|
366
|
+
() => openFile(savedPath),
|
|
367
|
+
)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
322
371
|
const params = new URLSearchParams()
|
|
323
372
|
params.set('container', container)
|
|
324
373
|
params.set('path', node.path)
|
|
@@ -335,7 +384,10 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
|
|
|
335
384
|
const blob = await response.blob()
|
|
336
385
|
await downloadBlob(blob, node.name)
|
|
337
386
|
} catch (err) {
|
|
338
|
-
|
|
387
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
388
|
+
if (message !== 'cancelled') {
|
|
389
|
+
showError(`Could not download ${node.name}`, message)
|
|
390
|
+
}
|
|
339
391
|
} finally {
|
|
340
392
|
setDownloading(false)
|
|
341
393
|
}
|