@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.
Files changed (67) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +37 -17
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +244 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +74 -1
  10. package/src/components/ConnectionErrorView.tsx +22 -20
  11. package/src/components/ContextSwitcher.tsx +10 -13
  12. package/src/components/applications/ApplicationsView.tsx +10 -9
  13. package/src/components/audit/AuditView.tsx +6 -3
  14. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  15. package/src/components/audit/UpgradeReadinessView.tsx +22 -12
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  17. package/src/components/capacity/schedulingBar.test.ts +10 -0
  18. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  19. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  20. package/src/components/cost/CostTrendChart.tsx +20 -10
  21. package/src/components/cost/CostView.tsx +79 -28
  22. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  23. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  24. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  25. package/src/components/cost/format.test.ts +27 -8
  26. package/src/components/cost/format.ts +78 -27
  27. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  28. package/src/components/gitops/GitOpsView.tsx +8 -3
  29. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  30. package/src/components/helm/OwnedResources.tsx +10 -2
  31. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  32. package/src/components/home/ClusterHealthCard.tsx +63 -1
  33. package/src/components/home/CostCard.tsx +12 -7
  34. package/src/components/home/HomeView.tsx +36 -11
  35. package/src/components/home/MCPSetupDialog.tsx +5 -4
  36. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  37. package/src/components/home/RadarVersionLine.tsx +137 -0
  38. package/src/components/home/mcpToolCatalog.ts +12 -0
  39. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  40. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  41. package/src/components/resources/ResourcesView.tsx +31 -8
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +181 -40
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
  48. package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
  49. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  50. package/src/components/ui/ErrorBoundary.tsx +17 -2
  51. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  52. package/src/components/ui/UpdateNotification.tsx +6 -2
  53. package/src/components/ui/command-items.ts +4 -14
  54. package/src/components/workload/WorkloadView.test.ts +60 -0
  55. package/src/components/workload/WorkloadView.tsx +272 -30
  56. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  57. package/src/contexts/CapabilitiesContext.tsx +7 -3
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.test.ts +45 -0
  62. package/src/utils/navigation.ts +5 -5
  63. package/src/utils/topology-selection.ts +3 -2
  64. package/src/utils/version.test.ts +37 -0
  65. package/src/utils/version.ts +56 -0
  66. package/src/utils/wails-clipboard.test.ts +109 -0
  67. package/src/utils/wails-clipboard.ts +127 -0
@@ -6,6 +6,7 @@ describe('getWorkloadCostState', () => {
6
6
  it('treats scaled-to-zero as a valid zero state', () => {
7
7
  const current: OpenCostWorkloadDetailResponse = {
8
8
  available: true,
9
+ currency: 'USD',
9
10
  namespace: 'default',
10
11
  kind: 'Deployment',
11
12
  name: 'checkout',
@@ -24,6 +25,7 @@ describe('getWorkloadCostState', () => {
24
25
  }
25
26
  const trend: OpenCostWorkloadTrendResponse = {
26
27
  available: false,
28
+ currency: 'USD',
27
29
  reason: 'no_metrics',
28
30
  namespace: 'default',
29
31
  kind: 'Deployment',
@@ -37,6 +39,7 @@ describe('getWorkloadCostState', () => {
37
39
  it('keeps current cost visible when only historical owner metrics are missing', () => {
38
40
  const current: OpenCostWorkloadDetailResponse = {
39
41
  available: true,
42
+ currency: 'USD',
40
43
  namespace: 'default',
41
44
  kind: 'Deployment',
42
45
  name: 'checkout',
@@ -55,6 +58,7 @@ describe('getWorkloadCostState', () => {
55
58
  }
56
59
  const trend: OpenCostWorkloadTrendResponse = {
57
60
  available: false,
61
+ currency: 'USD',
58
62
  reason: 'no_metrics',
59
63
  namespace: 'default',
60
64
  kind: 'Deployment',
@@ -68,6 +72,7 @@ describe('getWorkloadCostState', () => {
68
72
  it('keeps current cost visible while historical owner metrics are still loading', () => {
69
73
  const current: OpenCostWorkloadDetailResponse = {
70
74
  available: true,
75
+ currency: 'USD',
71
76
  namespace: 'default',
72
77
  kind: 'Deployment',
73
78
  name: 'checkout',
@@ -91,6 +96,7 @@ describe('getWorkloadCostState', () => {
91
96
  it('uses historical data when current metrics are absent but history exists', () => {
92
97
  const current: OpenCostWorkloadDetailResponse = {
93
98
  available: false,
99
+ currency: 'USD',
94
100
  reason: 'no_metrics',
95
101
  namespace: 'default',
96
102
  kind: 'Deployment',
@@ -98,6 +104,7 @@ describe('getWorkloadCostState', () => {
98
104
  }
99
105
  const trend: OpenCostWorkloadTrendResponse = {
100
106
  available: true,
107
+ currency: 'USD',
101
108
  namespace: 'default',
102
109
  kind: 'Deployment',
103
110
  name: 'checkout',
@@ -118,6 +125,7 @@ describe('getWorkloadCostState', () => {
118
125
  it('surfaces workload access and existence failures', () => {
119
126
  const current: OpenCostWorkloadDetailResponse = {
120
127
  available: false,
128
+ currency: 'USD',
121
129
  reason: 'access_denied',
122
130
  namespace: 'prod',
123
131
  kind: 'Deployment',
@@ -126,6 +134,7 @@ describe('getWorkloadCostState', () => {
126
134
 
127
135
  const missing: OpenCostWorkloadTrendResponse = {
128
136
  available: false,
137
+ currency: 'USD',
129
138
  reason: 'not_found',
130
139
  namespace: 'prod',
131
140
  kind: 'Deployment',
@@ -142,6 +151,7 @@ describe('getWorkloadCostState', () => {
142
151
  it('shows Prometheus discovery as soon as one query reports it', () => {
143
152
  const current: OpenCostWorkloadDetailResponse = {
144
153
  available: false,
154
+ currency: 'USD',
145
155
  reason: 'no_prometheus',
146
156
  namespace: 'default',
147
157
  kind: 'Deployment',
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
2
+ import { AlertCircle, Coins, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
3
3
  import {
4
4
  useOpenCostWorkload,
5
5
  useOpenCostWorkloadTrend,
@@ -12,6 +12,7 @@ import {
12
12
  import { Tooltip } from '../ui/Tooltip'
13
13
  import { CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart'
14
14
  import {
15
+ DEFAULT_COST_CURRENCY,
15
16
  formatCostPerHour,
16
17
  formatHistoricalSpend,
17
18
  formatProjectedDailyRate,
@@ -110,10 +111,13 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
110
111
  const windowTotal = trend?.available ? (trend.windowTotalCost ?? 0) : 0
111
112
  const cpuCost = current?.cpuCost ?? 0
112
113
  const memoryCost = current?.memoryCost ?? 0
114
+ const currentCurrency = currentQuery.data?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY
115
+ const trendCurrency = trend?.currency ?? currentQuery.data?.currency ?? DEFAULT_COST_CURRENCY
113
116
  const windowSpendValue = formatHistoricalSpend(
114
117
  points.length,
115
118
  windowTotal,
116
119
  trendLoading || state === 'partial_missing_history',
120
+ trendCurrency,
117
121
  )
118
122
 
119
123
  return (
@@ -127,10 +131,11 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
127
131
  <div className="text-sm font-semibold text-theme-text-primary">
128
132
  Historical compute cost
129
133
  </div>
130
- <MetricInfoTooltip content="Dollars are based on OpenCost CPU and memory allocation over time, not raw utilization. OpenCost allocation uses the greater of requested or observed resources." />
134
+ <MetricInfoTooltip content="Values are based on OpenCost CPU and memory allocation over time, not raw utilization. OpenCost allocation uses the greater of requested or observed resources." />
131
135
  </div>
132
136
  <div className="text-xs text-theme-text-tertiary">
133
- OpenCost CPU and memory allocation rate ($/hr) attributed by workload ownership
137
+ OpenCost CPU and memory allocation rate ({trendCurrency}/hr) attributed by workload
138
+ ownership
134
139
  </div>
135
140
  </div>
136
141
  </div>
@@ -148,10 +153,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
148
153
  />
149
154
  <MetricBlock
150
155
  label="Projected monthly"
151
- value={hasCurrent ? formatProjectedMonthlyCost(hourly) : '—'}
156
+ value={hasCurrent ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
152
157
  subvalue={
153
158
  hasCurrent
154
- ? `${formatCostPerHour(hourly)} current rate`
159
+ ? `${formatCostPerHour(hourly, currentCurrency)} current rate`
155
160
  : 'Current allocation unavailable'
156
161
  }
157
162
  />
@@ -163,7 +168,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
163
168
  Loading historical cost…
164
169
  </div>
165
170
  ) : hasTrend ? (
166
- <StackedAreaChart series={[{ namespace: 'Allocation rate', dataPoints: points }]} />
171
+ <StackedAreaChart
172
+ series={[{ namespace: 'Allocation rate', dataPoints: points }]}
173
+ currency={trendCurrency}
174
+ />
167
175
  ) : (
168
176
  <div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
169
177
  No historical workload owner cost points for this range.
@@ -195,16 +203,17 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
195
203
  <MetricTile label="Replicas" value={hasCurrent ? String(current?.replicas ?? 0) : '—'} />
196
204
  <MetricTile
197
205
  label="Projected daily"
198
- value={hasCurrent ? formatProjectedDailyRate(hourly) : '—'}
206
+ value={hasCurrent ? formatProjectedDailyRate(hourly, currentCurrency) : '—'}
199
207
  subvalue={
200
208
  hasCurrent
201
- ? `${formatCostPerHour(hourly)} current hourly rate`
209
+ ? `${formatCostPerHour(hourly, currentCurrency)} current hourly rate`
202
210
  : 'Current allocation unavailable'
203
211
  }
204
212
  />
205
213
  </div>
206
214
 
207
215
  <CurrentAllocationUse
216
+ currency={currentCurrency}
208
217
  dataAvailable={hasCurrent}
209
218
  cpuCost={cpuCost}
210
219
  memoryCost={memoryCost}
@@ -216,9 +225,12 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
216
225
  />
217
226
 
218
227
  <div className="text-xs text-theme-text-tertiary">
219
- Powered by OpenCost via Prometheus. Historical spend uses the selected range; projected
220
- monthly values multiply the current hourly allocation. Storage/PVC attribution remains at
221
- namespace and cluster level.
228
+ Powered by OpenCost via Prometheus.{' '}
229
+ {currentCurrency !== DEFAULT_COST_CURRENCY && (
230
+ <>Labeled {currentCurrency}; no conversion. </>
231
+ )}
232
+ Historical spend uses the selected range; projected monthly values multiply the current
233
+ hourly allocation. Storage/PVC attribution remains at namespace and cluster level.
222
234
  </div>
223
235
  </div>
224
236
  )
@@ -313,7 +325,7 @@ function WorkloadCostUnavailable({ state }: { state: CostUnavailableReason | 'lo
313
325
  return (
314
326
  <div className="flex h-full min-h-[320px] items-center justify-center">
315
327
  <div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
316
- <DollarSign className="h-8 w-8 text-theme-text-tertiary/50" />
328
+ <Coins className="h-8 w-8 text-theme-text-tertiary/50" />
317
329
  <div className="text-sm">{message}</div>
318
330
  </div>
319
331
  </div>
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
+ formatCostAxis,
3
4
  formatCostPerHour,
4
5
  formatHistoricalSpend,
5
6
  formatProjectedDailyRate,
@@ -9,19 +10,37 @@ import {
9
10
 
10
11
  describe('cost formatters', () => {
11
12
  it('formats projected run rates from hourly allocation', () => {
12
- expect(formatProjectedDailyRate(0.1)).toBe('~$2.40/day')
13
- expect(formatProjectedMonthlyCost(1)).toBe('~$730.00')
14
- expect(formatProjectedMonthlyRate(0.1)).toBe('~$73.00/mo')
13
+ expect(formatProjectedDailyRate(0.1, 'USD')).toBe('~$2.40/day')
14
+ expect(formatProjectedMonthlyCost(1, 'USD')).toBe('~$730.00')
15
+ expect(formatProjectedMonthlyRate(0.1, 'USD')).toBe('~$73.00/mo')
15
16
  })
16
17
 
17
18
  it('keeps hourly rates explicit', () => {
18
- expect(formatCostPerHour(0.1)).toBe('$0.100/hr')
19
+ expect(formatCostPerHour(0.1, 'USD')).toBe('$0.100/hr')
19
20
  })
20
21
 
21
22
  it('does not turn insufficient history into zero spend', () => {
22
- expect(formatHistoricalSpend(1, 0, false)).toBe('—')
23
- expect(formatHistoricalSpend(2, 0, false)).toBe('$0.00')
24
- expect(formatHistoricalSpend(2, 1.25, false)).toBe('~$1.25')
25
- expect(formatHistoricalSpend(2, 1.25, true)).toBe('—')
23
+ expect(formatHistoricalSpend(1, 0, false, 'USD')).toBe('—')
24
+ expect(formatHistoricalSpend(2, 0, false, 'USD')).toBe('$0.00')
25
+ expect(formatHistoricalSpend(2, 1.25, false, 'USD')).toBe('~$1.25')
26
+ expect(formatHistoricalSpend(2, 1.25, true, 'USD')).toBe('—')
27
+ })
28
+
29
+ it('uses the configured currency and its major-unit precision', () => {
30
+ expect(formatProjectedDailyRate(0.1, 'EUR')).toBe('~€2.40/day')
31
+ expect(formatCostPerHour(0.1, 'GBP')).toBe('£0.100/hr')
32
+ expect(formatProjectedMonthlyCost(1, 'JPY')).toBe('~¥730')
33
+ expect(formatCostPerHour(0.1, 'JPY')).toBe('¥0.100/hr')
34
+ expect(formatHistoricalSpend(2, 0, false, 'JPY')).toBe('¥0')
35
+ expect(formatCostAxis(0.000001, 'GBP')).toBe('<£0.00001')
36
+ })
37
+
38
+ it('normalizes codes and labels malformed currencies without impersonating USD', () => {
39
+ expect(formatProjectedMonthlyCost(1, ' eur ')).toBe('~€730.00')
40
+ expect(formatProjectedMonthlyCost(1, 'not-a-code')).toBe('~NOT-A-CODE 730.00')
41
+ })
42
+
43
+ it('falls back to USD when an older backend omits currency', () => {
44
+ expect(formatProjectedMonthlyCost(1, undefined)).toBe('~$730.00')
26
45
  })
27
46
  })
@@ -1,46 +1,97 @@
1
1
  export const COST_HOURS_PER_DAY = 24
2
2
  export const COST_HOURS_PER_MONTH = 730
3
+ export const DEFAULT_COST_CURRENCY = 'USD'
3
4
 
4
- export function formatCostAxis(value: number): string {
5
- if (!Number.isFinite(value) || value <= 0) return '$0'
6
- if (value >= 1000) return `$${(value / 1000).toFixed(0)}k`
7
- if (value >= 1) return `$${value.toFixed(1)}`
8
- if (value >= 0.01) return `$${value.toFixed(2)}`
9
- if (value >= 0.0001) return `$${value.toFixed(4)}`
10
- if (value >= 0.00001) return `$${value.toFixed(5)}`
11
- return '<$0.00001'
5
+ type CurrencyFormat = { formatter: Intl.NumberFormat; prefix: string }
6
+
7
+ const currencyFormatters = new Map<string, CurrencyFormat>()
8
+
9
+ function currencyFormatter(currency: string | undefined, digits?: number): CurrencyFormat {
10
+ const normalized = (currency ?? '').trim().toUpperCase() || DEFAULT_COST_CURRENCY
11
+ const key = `${normalized}:${digits ?? 'default'}`
12
+ const cached = currencyFormatters.get(key)
13
+ if (cached) return cached
14
+
15
+ try {
16
+ const options: Intl.NumberFormatOptions = {
17
+ style: 'currency',
18
+ currency: normalized,
19
+ }
20
+ if (digits !== undefined) {
21
+ options.minimumFractionDigits = digits
22
+ options.maximumFractionDigits = digits
23
+ }
24
+ const formatter = new Intl.NumberFormat('en-US', options)
25
+ const result = { formatter, prefix: '' }
26
+ currencyFormatters.set(key, result)
27
+ return result
28
+ } catch {
29
+ const fallbackDigits = digits ?? 2
30
+ const options: Intl.NumberFormatOptions = {
31
+ minimumFractionDigits: fallbackDigits,
32
+ maximumFractionDigits: fallbackDigits,
33
+ }
34
+ const result = {
35
+ formatter: new Intl.NumberFormat('en-US', options),
36
+ prefix: `${normalized} `,
37
+ }
38
+ currencyFormatters.set(key, result)
39
+ return result
40
+ }
41
+ }
42
+
43
+ function formatCurrency(value: number, currency: string | undefined, digits?: number): string {
44
+ const { formatter, prefix } = currencyFormatter(currency, digits)
45
+ return `${prefix}${formatter.format(value)}`
46
+ }
47
+
48
+ export function formatCostAxis(value: number, currency: string | undefined): string {
49
+ if (!Number.isFinite(value) || value <= 0) return formatCurrency(0, currency, 0)
50
+ if (value >= 1000) return `${formatCurrency(value / 1000, currency, 0)}k`
51
+ if (value >= 1) return formatCurrency(value, currency, 1)
52
+ if (value >= 0.01) return formatCurrency(value, currency, 2)
53
+ if (value >= 0.0001) return formatCurrency(value, currency, 4)
54
+ if (value >= 0.00001) return formatCurrency(value, currency, 5)
55
+ return `<${formatCurrency(0.00001, currency, 5)}`
12
56
  }
13
57
 
14
- export function formatCost(value: number): string {
15
- if (!Number.isFinite(value) || value <= 0) return '$0.00'
16
- if (value >= 1000) return `$${(value / 1000).toFixed(1)}k`
17
- if (value >= 1) return `$${value.toFixed(2)}`
18
- if (value >= 0.01) return `$${value.toFixed(3)}`
19
- if (value >= 0.0001) return `$${value.toFixed(4)}`
20
- return formatCostAxis(value)
58
+ export function formatCost(value: number, currency: string | undefined): string {
59
+ if (!Number.isFinite(value) || value <= 0) return formatCurrency(0, currency)
60
+ if (value >= 1000) return `${formatCurrency(value / 1000, currency, 1)}k`
61
+ if (value >= 1) return formatCurrency(value, currency)
62
+ if (value >= 0.01) return formatCurrency(value, currency, 3)
63
+ if (value >= 0.0001) return formatCurrency(value, currency, 4)
64
+ return formatCostAxis(value, currency)
21
65
  }
22
66
 
23
- export function formatCostPerHour(value: number): string {
24
- return `${formatCost(value)}/hr`
67
+ export function formatCostPerHour(value: number, currency: string | undefined): string {
68
+ return `${formatCost(value, currency)}/hr`
25
69
  }
26
70
 
27
- export function formatHistoricalSpend(pointCount: number, windowTotalCost: number, unavailable: boolean): string {
71
+ export function formatHistoricalSpend(
72
+ pointCount: number,
73
+ windowTotalCost: number,
74
+ unavailable: boolean,
75
+ currency: string | undefined,
76
+ ): string {
28
77
  if (unavailable || pointCount < 2) return '—'
29
- return windowTotalCost > 0 ? `~${formatCost(windowTotalCost)}` : formatCost(0)
78
+ return windowTotalCost > 0
79
+ ? `~${formatCost(windowTotalCost, currency)}`
80
+ : formatCost(0, currency)
30
81
  }
31
82
 
32
- export function formatProjectedDailyCost(hourlyCost: number): string {
33
- return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY)}`
83
+ export function formatProjectedDailyCost(hourlyCost: number, currency: string | undefined): string {
84
+ return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY, currency)}`
34
85
  }
35
86
 
36
- export function formatProjectedDailyRate(hourlyCost: number): string {
37
- return `${formatProjectedDailyCost(hourlyCost)}/day`
87
+ export function formatProjectedDailyRate(hourlyCost: number, currency: string | undefined): string {
88
+ return `${formatProjectedDailyCost(hourlyCost, currency)}/day`
38
89
  }
39
90
 
40
- export function formatProjectedMonthlyCost(hourlyCost: number): string {
41
- return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH)}`
91
+ export function formatProjectedMonthlyCost(hourlyCost: number, currency: string | undefined): string {
92
+ return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH, currency)}`
42
93
  }
43
94
 
44
- export function formatProjectedMonthlyRate(hourlyCost: number): string {
45
- return `${formatProjectedMonthlyCost(hourlyCost)}/mo`
95
+ export function formatProjectedMonthlyRate(hourlyCost: number, currency: string | undefined): string {
96
+ return `${formatProjectedMonthlyCost(hourlyCost, currency)}/mo`
46
97
  }
@@ -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
- <button
126
- onClick={() => {
127
- openInTerminal(command, "Diagnose");
128
- setOpen(false);
129
- }}
130
- 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"
131
- >
132
- <TerminalSquare className="h-4 w-4 shrink-0 text-theme-text-tertiary" />
133
- Run in a Radar terminal
134
- </button>
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
- kindToPlural,
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 = kindToPlural(ref.kind)
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({ kind: kindToPlural(ref.kind), namespace: ref.namespace || '', name: ref.name, group: ref.group })
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, kindToPlural, type NavigateToResource } from '../../utils/navigation'
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: kindToPlural(resource.kind),
1250
+ kind: kindToPluralWithGroup(resource.kind, group),
1250
1251
  namespace: resource.namespace,
1251
1252
  name: resource.name,
1252
- group: apiVersionToGroup(resource.apiVersion),
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 { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
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({ kind: kindToPlural(resource.kind), namespace: resource.namespace, name: resource.name, group: apiVersionToGroup(resource.apiVersion) })
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 {
@@ -90,6 +94,9 @@ function getPlatformInfo(platform: string): { name: string; icon: string | null
90
94
  if (platformLower.includes('openshift')) {
91
95
  return { name: 'OpenShift', icon: null }
92
96
  }
97
+ if (platformLower.includes('rke2')) {
98
+ return { name: 'RKE2', icon: null }
99
+ }
93
100
  if (platformLower.includes('rancher')) {
94
101
  return { name: 'Rancher', icon: null }
95
102
  }
@@ -127,9 +134,11 @@ export function ClusterHealthCard({
127
134
  nodeVersionSkew,
128
135
  onNavigateToKind,
129
136
  onNavigateToView,
137
+ onNavigateToUpgradeImpact,
130
138
  onWarningEventsClick,
131
139
  onIssuesClick,
132
140
  freshness,
141
+ radarVersion,
133
142
  }: ClusterHealthCardProps) {
134
143
  void _topCRDs // Reserved for future CRD display
135
144
 
@@ -248,8 +257,13 @@ export function ClusterHealthCard({
248
257
  </Tooltip>
249
258
  )}
250
259
  {cluster.version && (
251
- <span>Kubernetes {cluster.version}</span>
260
+ <KubernetesVersionLine
261
+ version={cluster.version}
262
+ reviewedThrough={cluster.upgradeReviewedThrough}
263
+ onNavigate={onNavigateToUpgradeImpact}
264
+ />
252
265
  )}
266
+ {radarVersion}
253
267
  <span><span className="font-mono">{counts.namespaces}</span> namespaces</span>
254
268
  {/* Show raw kubeconfig context as muted metadata only when
255
269
  it differs from the headline AND we're in local mode
@@ -516,6 +530,54 @@ export function ClusterHealthCard({
516
530
  )
517
531
  }
518
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
+
519
581
  function RestrictedRing({ label }: { label: string }) {
520
582
  const radius = 36
521
583
  const circumference = 2 * Math.PI * radius