@skyhook-io/radar-app 1.8.7 → 1.8.8

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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +58 -50
  3. package/src/api/client.argoResourceSync.test.ts +69 -0
  4. package/src/api/client.rightsizing.test.ts +32 -0
  5. package/src/api/client.ts +1222 -234
  6. package/src/api/timelineSource.ts +4 -2
  7. package/src/components/applications/ApplicationsView.tsx +613 -219
  8. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  9. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  10. package/src/components/cost/CostTrendChart.tsx +103 -72
  11. package/src/components/cost/CostView.test.ts +12 -0
  12. package/src/components/cost/CostView.tsx +494 -229
  13. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  14. package/src/components/cost/CostViewTabs.tsx +40 -0
  15. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  16. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  17. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  19. package/src/components/cost/cloud-console.test.ts +39 -0
  20. package/src/components/cost/cloud-console.ts +81 -0
  21. package/src/components/cost/errors.ts +8 -0
  22. package/src/components/cost/format.test.ts +27 -0
  23. package/src/components/cost/format.ts +46 -0
  24. package/src/components/cost/kinds.ts +5 -0
  25. package/src/components/diagnose/AISettings.tsx +7 -12
  26. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  27. package/src/components/gitops/GitOpsView.tsx +81 -14
  28. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  29. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  30. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  31. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  32. package/src/components/home/CostCard.tsx +21 -36
  33. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  34. package/src/components/resource/RightsizingStrip.tsx +319 -123
  35. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  36. package/src/components/rightsizing/copy.test.ts +56 -0
  37. package/src/components/rightsizing/model.test.ts +227 -0
  38. package/src/components/rightsizing/model.ts +158 -0
  39. package/src/components/rightsizing/presentation.test.ts +104 -0
  40. package/src/components/rightsizing/presentation.ts +94 -0
  41. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  42. package/src/components/settings/SettingsDialog.tsx +1268 -318
  43. package/src/components/timeline/TimelineList.tsx +35 -8
  44. package/src/components/timeline/TimelineView.tsx +156 -26
  45. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  46. package/src/components/workload/WorkloadView.tsx +711 -328
  47. package/src/index.css +5 -1
  48. package/src/main.tsx +1 -1
@@ -0,0 +1,372 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
3
+ import {
4
+ useOpenCostWorkload,
5
+ useOpenCostWorkloadTrend,
6
+ COST_DISCOVERY_GRACE_MS,
7
+ type CostTimeRange,
8
+ type CostUnavailableReason,
9
+ type OpenCostWorkloadDetailResponse,
10
+ type OpenCostWorkloadTrendResponse,
11
+ } from '../../api/client'
12
+ import { Tooltip } from '../ui/Tooltip'
13
+ import { CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart'
14
+ import {
15
+ formatCostPerHour,
16
+ formatHistoricalSpend,
17
+ formatProjectedDailyRate,
18
+ formatProjectedMonthlyCost,
19
+ } from './format'
20
+ import { CurrentAllocationUse } from './CurrentAllocationUse'
21
+ import { costUnavailableReasonFromError } from './errors'
22
+
23
+ type WorkloadCostState =
24
+ | 'loading'
25
+ | 'data'
26
+ | 'partial_missing_history'
27
+ | 'partial_missing_current'
28
+ | 'zero'
29
+ | 'load_error'
30
+ | CostUnavailableReason
31
+
32
+ interface WorkloadCostQueryStatus {
33
+ currentLoading?: boolean
34
+ trendLoading?: boolean
35
+ currentError?: unknown
36
+ trendError?: unknown
37
+ }
38
+
39
+ interface WorkloadCostTabProps {
40
+ kind: string
41
+ namespace: string
42
+ name: string
43
+ }
44
+
45
+ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) {
46
+ const [range, setRange] = useState<CostTimeRange>('24h')
47
+ const [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
48
+ const currentQuery = useOpenCostWorkload(kind, namespace, name)
49
+ const trendQuery = useOpenCostWorkloadTrend(kind, namespace, name, range)
50
+ const trendMatchesRange = trendQuery.data?.range === range
51
+ const trendData = trendMatchesRange ? trendQuery.data : undefined
52
+ const trendLoading =
53
+ trendQuery.isLoading ||
54
+ (trendQuery.isFetching && Boolean(trendQuery.data) && !trendMatchesRange)
55
+
56
+ const state = getWorkloadCostState(currentQuery.data, trendData, {
57
+ currentLoading: currentQuery.isLoading,
58
+ trendLoading,
59
+ currentError: currentQuery.error,
60
+ trendError: trendQuery.error,
61
+ })
62
+
63
+ useEffect(() => {
64
+ if (state === 'no_prometheus') {
65
+ setNoPrometheusSince((prev) => prev ?? Date.now())
66
+ } else {
67
+ setNoPrometheusSince(null)
68
+ }
69
+ }, [state])
70
+
71
+ if (state === 'loading') {
72
+ return (
73
+ <div className="flex h-full min-h-[320px] items-center justify-center text-theme-text-tertiary">
74
+ <Loader2 className="mr-2 h-5 w-5 animate-spin" />
75
+ Loading workload cost…
76
+ </div>
77
+ )
78
+ }
79
+
80
+ if (
81
+ state === 'no_prometheus' ||
82
+ state === 'no_metrics' ||
83
+ state === 'query_error' ||
84
+ state === 'access_denied' ||
85
+ state === 'not_found' ||
86
+ state === 'load_error'
87
+ ) {
88
+ const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
89
+ if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
90
+ return (
91
+ <WorkloadCostDiscovering
92
+ isFetching={currentQuery.isFetching || trendQuery.isFetching}
93
+ onRetry={() => {
94
+ setNoPrometheusSince(Date.now())
95
+ currentQuery.refetch()
96
+ trendQuery.refetch()
97
+ }}
98
+ />
99
+ )
100
+ }
101
+ return <WorkloadCostUnavailable state={state} />
102
+ }
103
+
104
+ const current = currentQuery.data?.current
105
+ const trend = trendData
106
+ const points = trend?.available ? (trend.dataPoints ?? []) : []
107
+ const hasTrend = points.length >= 2 && points.some((p) => p.value > 0)
108
+ const hasCurrent = Boolean(current)
109
+ const hourly = current?.hourlyCost ?? 0
110
+ const windowTotal = trend?.available ? (trend.windowTotalCost ?? 0) : 0
111
+ const cpuCost = current?.cpuCost ?? 0
112
+ const memoryCost = current?.memoryCost ?? 0
113
+ const windowSpendValue = formatHistoricalSpend(
114
+ points.length,
115
+ windowTotal,
116
+ trendLoading || state === 'partial_missing_history',
117
+ )
118
+
119
+ return (
120
+ <div className="mx-auto w-full max-w-[1600px] space-y-4">
121
+ <section className="rounded-lg border border-theme-border bg-theme-surface/50">
122
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-theme-border px-4 py-3">
123
+ <div className="flex items-center gap-2">
124
+ <TrendingUp className="h-4 w-4 text-theme-text-tertiary" />
125
+ <div>
126
+ <div className="flex items-center gap-1.5">
127
+ <div className="text-sm font-semibold text-theme-text-primary">
128
+ Historical compute cost
129
+ </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." />
131
+ </div>
132
+ <div className="text-xs text-theme-text-tertiary">
133
+ OpenCost CPU and memory allocation rate ($/hr) attributed by workload ownership
134
+ </div>
135
+ </div>
136
+ </div>
137
+ <CostTimeRangeSelector value={range} onChange={setRange} />
138
+ </div>
139
+
140
+ <div className="grid gap-4 p-4 lg:grid-cols-[220px_minmax(0,1fr)]">
141
+ <div className="space-y-4">
142
+ <MetricBlock
143
+ label={`Spend over ${range}`}
144
+ value={windowSpendValue}
145
+ subvalue={
146
+ state === 'partial_missing_history' ? 'Historical data unavailable' : undefined
147
+ }
148
+ />
149
+ <MetricBlock
150
+ label="Projected monthly"
151
+ value={hasCurrent ? formatProjectedMonthlyCost(hourly) : '—'}
152
+ subvalue={
153
+ hasCurrent
154
+ ? `${formatCostPerHour(hourly)} current rate`
155
+ : 'Current allocation unavailable'
156
+ }
157
+ />
158
+ </div>
159
+ <div className="min-w-0">
160
+ {trendLoading ? (
161
+ <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">
162
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
163
+ Loading historical cost…
164
+ </div>
165
+ ) : hasTrend ? (
166
+ <StackedAreaChart series={[{ namespace: 'Allocation rate', dataPoints: points }]} />
167
+ ) : (
168
+ <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
+ No historical workload owner cost points for this range.
170
+ </div>
171
+ )}
172
+ </div>
173
+ </div>
174
+ </section>
175
+
176
+ {state === 'partial_missing_history' && (
177
+ <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-secondary">
178
+ <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
179
+ <span>
180
+ Current cost is available, but historical workload owner metrics are not available for
181
+ this range.
182
+ </span>
183
+ </div>
184
+ )}
185
+ {state === 'partial_missing_current' && (
186
+ <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-secondary">
187
+ <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
188
+ <span>
189
+ Historical cost is available, but current workload allocation metrics are not available.
190
+ </span>
191
+ </div>
192
+ )}
193
+
194
+ <div className="grid gap-4 md:grid-cols-2">
195
+ <MetricTile label="Replicas" value={hasCurrent ? String(current?.replicas ?? 0) : '—'} />
196
+ <MetricTile
197
+ label="Projected daily"
198
+ value={hasCurrent ? formatProjectedDailyRate(hourly) : '—'}
199
+ subvalue={
200
+ hasCurrent
201
+ ? `${formatCostPerHour(hourly)} current hourly rate`
202
+ : 'Current allocation unavailable'
203
+ }
204
+ />
205
+ </div>
206
+
207
+ <CurrentAllocationUse
208
+ dataAvailable={hasCurrent}
209
+ cpuCost={cpuCost}
210
+ memoryCost={memoryCost}
211
+ hourlyCost={hourly}
212
+ cpuAllocationUse={current?.cpuAllocationUse ?? 0}
213
+ memoryAllocationUse={current?.memoryAllocationUse ?? 0}
214
+ cpuUsageAvailable={current?.cpuUsageAvailable ?? false}
215
+ memoryUsageAvailable={current?.memoryUsageAvailable ?? false}
216
+ />
217
+
218
+ <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.
222
+ </div>
223
+ </div>
224
+ )
225
+ }
226
+
227
+ export function getWorkloadCostState(
228
+ current: OpenCostWorkloadDetailResponse | undefined,
229
+ trend: OpenCostWorkloadTrendResponse | undefined,
230
+ status: boolean | WorkloadCostQueryStatus,
231
+ ): WorkloadCostState {
232
+ const queryStatus: WorkloadCostQueryStatus =
233
+ typeof status === 'boolean' ? { currentLoading: status, trendLoading: status } : status
234
+ const loading = Boolean(queryStatus.currentLoading || queryStatus.trendLoading)
235
+ const queryError = Boolean(queryStatus.currentError || queryStatus.trendError)
236
+
237
+ const currentRow = current?.available ? current.current : undefined
238
+ const trendHasData =
239
+ trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0)
240
+ if (currentRow) {
241
+ if (queryStatus.trendLoading && !trend) return 'data'
242
+ if (queryStatus.trendError || (trend?.available === false && trend.reason !== 'no_metrics'))
243
+ return 'partial_missing_history'
244
+ if (currentRow.hourlyCost === 0 && currentRow.replicas === 0 && !trendHasData) return 'zero'
245
+ if (!trend?.available) return 'partial_missing_history'
246
+ return 'data'
247
+ }
248
+ if (trendHasData) return 'partial_missing_current'
249
+ const reason =
250
+ current?.reason ??
251
+ trend?.reason ??
252
+ costUnavailableReasonFromError(queryStatus.currentError) ??
253
+ costUnavailableReasonFromError(queryStatus.trendError)
254
+ if (
255
+ reason === 'no_prometheus' ||
256
+ reason === 'query_error' ||
257
+ reason === 'access_denied' ||
258
+ reason === 'not_found'
259
+ )
260
+ return reason
261
+ if (queryError) return 'load_error'
262
+ if (loading) return 'loading'
263
+
264
+ return 'no_metrics'
265
+ }
266
+
267
+ function WorkloadCostDiscovering({
268
+ isFetching,
269
+ onRetry,
270
+ }: {
271
+ isFetching: boolean
272
+ onRetry: () => void
273
+ }) {
274
+ return (
275
+ <div className="flex h-full min-h-[320px] items-center justify-center">
276
+ <div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
277
+ <Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary/60" />
278
+ <div>
279
+ <p className="text-sm font-medium text-theme-text-primary">
280
+ Looking for Prometheus cost data…
281
+ </p>
282
+ <p className="mt-1 text-xs text-theme-text-tertiary">
283
+ First discovery can take a few seconds while Radar checks cluster services and opens a
284
+ local port-forward.
285
+ </p>
286
+ </div>
287
+ <button
288
+ onClick={onRetry}
289
+ disabled={isFetching}
290
+ className="text-xs text-accent-text transition-colors hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled"
291
+ >
292
+ {isFetching ? 'Checking…' : 'Check again'}
293
+ </button>
294
+ </div>
295
+ </div>
296
+ )
297
+ }
298
+
299
+ function WorkloadCostUnavailable({ state }: { state: CostUnavailableReason | 'load_error' }) {
300
+ const message =
301
+ state === 'no_prometheus'
302
+ ? 'Prometheus not found. OpenCost workload cost requires Prometheus or VictoriaMetrics.'
303
+ : state === 'query_error'
304
+ ? 'Cost data is temporarily unavailable. Prometheus was found, but workload cost queries failed.'
305
+ : state === 'access_denied'
306
+ ? 'You do not have access to view cost for this workload.'
307
+ : state === 'not_found'
308
+ ? 'This workload no longer exists.'
309
+ : state === 'load_error'
310
+ ? 'Could not load workload cost data. Check access to this workload and try again.'
311
+ : 'OpenCost workload metrics were not found for this workload.'
312
+
313
+ return (
314
+ <div className="flex h-full min-h-[320px] items-center justify-center">
315
+ <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" />
317
+ <div className="text-sm">{message}</div>
318
+ </div>
319
+ </div>
320
+ )
321
+ }
322
+
323
+ function MetricBlock({
324
+ label,
325
+ value,
326
+ subvalue,
327
+ }: {
328
+ label: string
329
+ value: string
330
+ subvalue?: string
331
+ }) {
332
+ return (
333
+ <div>
334
+ <div className="text-xs font-medium uppercase text-theme-text-tertiary">{label}</div>
335
+ <div className="mt-1 text-2xl font-semibold text-theme-text-primary tabular-nums">
336
+ {value}
337
+ </div>
338
+ {subvalue && <div className="mt-1 text-xs text-theme-text-tertiary">{subvalue}</div>}
339
+ </div>
340
+ )
341
+ }
342
+
343
+ function MetricTile({
344
+ label,
345
+ value,
346
+ subvalue,
347
+ tooltip,
348
+ }: {
349
+ label: string
350
+ value: string
351
+ subvalue?: string
352
+ tooltip?: string
353
+ }) {
354
+ return (
355
+ <div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
356
+ <div className="flex items-center gap-1.5">
357
+ <div className="text-xs font-medium uppercase text-theme-text-tertiary">{label}</div>
358
+ {tooltip && <MetricInfoTooltip content={tooltip} />}
359
+ </div>
360
+ <div className="mt-1 text-lg font-semibold text-theme-text-primary tabular-nums">{value}</div>
361
+ {subvalue && <div className="mt-1 text-xs text-theme-text-tertiary">{subvalue}</div>}
362
+ </div>
363
+ )
364
+ }
365
+
366
+ function MetricInfoTooltip({ content }: { content: string }) {
367
+ return (
368
+ <Tooltip content={content} className="max-w-[280px] whitespace-normal text-left" delay={150}>
369
+ <HelpCircle className="h-3.5 w-3.5 cursor-help text-theme-text-tertiary transition-colors hover:text-theme-text-secondary" />
370
+ </Tooltip>
371
+ )
372
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console'
3
+
4
+ describe('cloud console links', () => {
5
+ it('builds Google Compute Engine node links from GCE provider IDs', () => {
6
+ expect(nodeCloudConsoleLink('gce://proj-1/us-east1-b/gke-node-1')).toEqual({
7
+ label: 'Open in Google Cloud Console',
8
+ url: 'https://console.cloud.google.com/compute/instancesDetail/zones/us-east1-b/instances/gke-node-1?project=proj-1',
9
+ })
10
+ })
11
+
12
+ it('builds AWS EC2 node links from AWS provider IDs', () => {
13
+ expect(nodeCloudConsoleLink('aws:///us-east-1a/i-0123456789abcdef0')).toEqual({
14
+ label: 'Open in AWS Console',
15
+ url: 'https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#InstanceDetails:instanceId=i-0123456789abcdef0',
16
+ })
17
+ })
18
+
19
+ it('omits ambiguous node links', () => {
20
+ expect(nodeCloudConsoleLink('aws:///us-east-1-wl1-bos-wlz-1/i-0123456789abcdef0')).toBeNull()
21
+ expect(nodeCloudConsoleLink('azure:///subscriptions/123/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm')).toBeNull()
22
+ })
23
+
24
+ it('builds high-confidence cluster links from canonical contexts', () => {
25
+ expect(clusterCloudConsoleLink('gke_proj-1_us-east1_nonprod')).toEqual({
26
+ label: 'Open cluster in Google Cloud Console',
27
+ url: 'https://console.cloud.google.com/kubernetes/clusters/details/us-east1/nonprod/details?project=proj-1',
28
+ })
29
+ expect(clusterCloudConsoleLink('arn:aws:eks:us-west-2:123456789012:cluster/prod')).toEqual({
30
+ label: 'Open cluster in AWS Console',
31
+ url: 'https://us-west-2.console.aws.amazon.com/eks/home?region=us-west-2#/clusters/prod',
32
+ })
33
+ })
34
+
35
+ it('omits renamed or non-commercial-partition cluster contexts', () => {
36
+ expect(clusterCloudConsoleLink('nonprod-cluster-us-east1')).toBeNull()
37
+ expect(clusterCloudConsoleLink('arn:aws-us-gov:eks:us-gov-west-1:123456789012:cluster/prod')).toBeNull()
38
+ })
39
+ })
@@ -0,0 +1,81 @@
1
+ export interface CloudConsoleLink {
2
+ label: string
3
+ url: string
4
+ }
5
+
6
+ export function nodeCloudConsoleLink(providerID?: string): CloudConsoleLink | null {
7
+ if (!providerID) return null
8
+
9
+ const gce = parseGCEProviderID(providerID)
10
+ if (gce) {
11
+ return {
12
+ label: 'Open in Google Cloud Console',
13
+ url: `https://console.cloud.google.com/compute/instancesDetail/zones/${encodeURIComponent(gce.zone)}/instances/${encodeURIComponent(gce.instance)}?project=${encodeURIComponent(gce.project)}`,
14
+ }
15
+ }
16
+
17
+ const aws = parseAWSProviderID(providerID)
18
+ if (aws) {
19
+ return {
20
+ label: 'Open in AWS Console',
21
+ url: `https://${aws.region}.console.aws.amazon.com/ec2/home?region=${aws.region}#InstanceDetails:instanceId=${encodeURIComponent(aws.instanceID)}`,
22
+ }
23
+ }
24
+
25
+ return null
26
+ }
27
+
28
+ export function clusterCloudConsoleLink(context?: string): CloudConsoleLink | null {
29
+ if (!context) return null
30
+
31
+ const gke = parseGKEContext(context)
32
+ if (gke) {
33
+ return {
34
+ label: 'Open cluster in Google Cloud Console',
35
+ url: `https://console.cloud.google.com/kubernetes/clusters/details/${encodeURIComponent(gke.location)}/${encodeURIComponent(gke.cluster)}/details?project=${encodeURIComponent(gke.project)}`,
36
+ }
37
+ }
38
+
39
+ const eks = parseEKSContext(context)
40
+ if (eks) {
41
+ return {
42
+ label: 'Open cluster in AWS Console',
43
+ url: `https://${eks.region}.console.aws.amazon.com/eks/home?region=${eks.region}#/clusters/${encodeURIComponent(eks.cluster)}`,
44
+ }
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ function parseGCEProviderID(providerID: string): { project: string; zone: string; instance: string } | null {
51
+ if (!providerID.startsWith('gce://')) return null
52
+ const parts = providerID.replace(/^gce:\/\/\/?/, '').split('/')
53
+ if (parts.length !== 3 || parts.some((part) => part === '')) return null
54
+ return { project: parts[0], zone: parts[1], instance: parts[2] }
55
+ }
56
+
57
+ function parseAWSProviderID(providerID: string): { region: string; instanceID: string } | null {
58
+ if (!providerID.startsWith('aws://')) return null
59
+ const parts = providerID.replace(/^aws:\/\/\/?/, '').split('/')
60
+ if (parts.length !== 2 || parts.some((part) => part === '')) return null
61
+ const region = regionFromAWSZone(parts[0])
62
+ if (!region) return null
63
+ return { region, instanceID: parts[1] }
64
+ }
65
+
66
+ function parseGKEContext(context: string): { project: string; location: string; cluster: string } | null {
67
+ const match = /^gke_([^_]+)_([^_]+)_(.+)$/.exec(context)
68
+ if (!match) return null
69
+ return { project: match[1], location: match[2], cluster: match[3] }
70
+ }
71
+
72
+ function parseEKSContext(context: string): { region: string; cluster: string } | null {
73
+ const match = /^arn:aws:eks:([^:]+):\d{12}:cluster\/(.+)$/.exec(context)
74
+ if (!match) return null
75
+ return { region: match[1], cluster: match[2] }
76
+ }
77
+
78
+ function regionFromAWSZone(zone: string): string | null {
79
+ const match = /^([a-z]{2}-[a-z]+-\d)[a-z]$/.exec(zone)
80
+ return match?.[1] ?? null
81
+ }
@@ -0,0 +1,8 @@
1
+ import { ApiError, type CostUnavailableReason } from '../../api/client'
2
+
3
+ export function costUnavailableReasonFromError(error: unknown): CostUnavailableReason | undefined {
4
+ if (!(error instanceof ApiError)) return undefined
5
+ if (error.status === 403) return 'access_denied'
6
+ if (error.status === 404) return 'not_found'
7
+ return undefined
8
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ formatCostPerHour,
4
+ formatHistoricalSpend,
5
+ formatProjectedDailyRate,
6
+ formatProjectedMonthlyCost,
7
+ formatProjectedMonthlyRate,
8
+ } from './format'
9
+
10
+ describe('cost formatters', () => {
11
+ 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')
15
+ })
16
+
17
+ it('keeps hourly rates explicit', () => {
18
+ expect(formatCostPerHour(0.1)).toBe('$0.100/hr')
19
+ })
20
+
21
+ 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('—')
26
+ })
27
+ })
@@ -0,0 +1,46 @@
1
+ export const COST_HOURS_PER_DAY = 24
2
+ export const COST_HOURS_PER_MONTH = 730
3
+
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'
12
+ }
13
+
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)
21
+ }
22
+
23
+ export function formatCostPerHour(value: number): string {
24
+ return `${formatCost(value)}/hr`
25
+ }
26
+
27
+ export function formatHistoricalSpend(pointCount: number, windowTotalCost: number, unavailable: boolean): string {
28
+ if (unavailable || pointCount < 2) return '—'
29
+ return windowTotalCost > 0 ? `~${formatCost(windowTotalCost)}` : formatCost(0)
30
+ }
31
+
32
+ export function formatProjectedDailyCost(hourlyCost: number): string {
33
+ return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY)}`
34
+ }
35
+
36
+ export function formatProjectedDailyRate(hourlyCost: number): string {
37
+ return `${formatProjectedDailyCost(hourlyCost)}/day`
38
+ }
39
+
40
+ export function formatProjectedMonthlyCost(hourlyCost: number): string {
41
+ return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH)}`
42
+ }
43
+
44
+ export function formatProjectedMonthlyRate(hourlyCost: number): string {
45
+ return `${formatProjectedMonthlyCost(hourlyCost)}/mo`
46
+ }
@@ -0,0 +1,5 @@
1
+ const OPEN_COST_WORKLOAD_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
2
+
3
+ export function isOpenCostWorkloadKind(kind: string): boolean {
4
+ return OPEN_COST_WORKLOAD_KINDS.has(kind)
5
+ }
@@ -1,6 +1,8 @@
1
- // The "AI Diagnosis" section of the Settings dialog. Controlled by the dialog:
2
- // it edits a STAGED draft and is committed on Save (like the rest of Settings),
3
- // not on every keystroke. Renders nothing when no supported agent CLI is installed.
1
+ // The agent/model/effort controls + a clear-history action for the Settings
2
+ // "AI diagnose" tab. Controlled by the dialog: it edits a STAGED draft committed
3
+ // on Save (like the rest of Settings), not on every keystroke. The heading,
4
+ // description, and Save button live in the dialog so this tab matches the other
5
+ // Settings tabs' layout — this renders only the controls (no card, no heading).
4
6
  import { useState } from "react";
5
7
  import { Trash2 } from "lucide-react";
6
8
  import { clearHistory, type AgentInfo } from "../../api/diagnose";
@@ -95,14 +97,7 @@ export function AISettingsSection({
95
97
  }) {
96
98
  if (!available || agents.length === 0) return null;
97
99
  return (
98
- <section className="mb-5 rounded-md border border-theme-border bg-theme-elevated/50 p-3">
99
- <h3 className="mb-1 text-sm font-medium text-theme-text-primary">
100
- AI Diagnosis
101
- </h3>
102
- <p className="mb-3 text-xs text-theme-text-tertiary">
103
- Investigations run on your own machine via your installed agent CLI — no
104
- Radar cloud, no API key. These preferences apply to new investigations.
105
- </p>
100
+ <>
106
101
  <AgentControls
107
102
  agents={agents}
108
103
  selectedAgent={draft.agent}
@@ -116,6 +111,6 @@ export function AISettingsSection({
116
111
  onSetEffort={(v) => onChange({ effort: v })}
117
112
  />
118
113
  <ClearHistoryRow onCleared={onHistoryCleared} />
119
- </section>
114
+ </>
120
115
  );
121
116
  }
@@ -0,0 +1,23 @@
1
+ import { ArgoResourceDiff } from '@skyhook-io/k8s-ui'
2
+ import type { GitOpsInsightRef } from '@skyhook-io/k8s-ui'
3
+ import { useArgoResourceDiff } from '../../api/client'
4
+
5
+ interface ArgoResourceDiffLoaderProps {
6
+ appNamespace: string
7
+ appName: string
8
+ resourceRef: GitOpsInsightRef
9
+ }
10
+
11
+ // Host wrapper: fetches the Argo CD resource diff and hands the data to the
12
+ // pure k8s-ui presentation component. Mirrors the data-in-web / render-in-
13
+ // k8s-ui split the resource renderers use for their host-wired data.
14
+ export function ArgoResourceDiffLoader({ appNamespace, appName, resourceRef }: ArgoResourceDiffLoaderProps) {
15
+ const { data, isLoading, error } = useArgoResourceDiff(appNamespace, appName, resourceRef)
16
+ return (
17
+ <ArgoResourceDiff
18
+ diff={data ?? null}
19
+ loading={isLoading}
20
+ error={(error as Error | null)?.message ?? null}
21
+ />
22
+ )
23
+ }