@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
|
@@ -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,
|
|
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="
|
|
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 (
|
|
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
|
|
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.
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
16
|
-
if (value >= 1000) return
|
|
17
|
-
if (value >= 1) return
|
|
18
|
-
if (value >= 0.01) return
|
|
19
|
-
if (value >= 0.0001) return
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 {
|
|
@@ -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
|
-
<
|
|
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
|