@skyhook-io/radar-app 1.8.7 → 1.8.9

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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +69 -61
  3. package/src/RadarApp.tsx +15 -1
  4. package/src/api/apiResources.ts +1 -1
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.rightsizing.test.ts +32 -0
  7. package/src/api/client.ts +1222 -244
  8. package/src/api/timelineSource.ts +4 -2
  9. package/src/components/applications/ApplicationsView.tsx +613 -219
  10. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  11. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  12. package/src/components/cost/CostTrendChart.tsx +103 -72
  13. package/src/components/cost/CostView.test.ts +12 -0
  14. package/src/components/cost/CostView.tsx +494 -229
  15. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  16. package/src/components/cost/CostViewTabs.tsx +40 -0
  17. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  18. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  19. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  20. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  21. package/src/components/cost/cloud-console.test.ts +39 -0
  22. package/src/components/cost/cloud-console.ts +81 -0
  23. package/src/components/cost/errors.ts +8 -0
  24. package/src/components/cost/format.test.ts +27 -0
  25. package/src/components/cost/format.ts +46 -0
  26. package/src/components/cost/kinds.ts +5 -0
  27. package/src/components/diagnose/AISettings.tsx +7 -12
  28. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  29. package/src/components/diagnose/DiagnoseSurface.tsx +3 -0
  30. package/src/components/diagnose/InvestigationView.tsx +16 -3
  31. package/src/components/diagnose/parts.tsx +140 -73
  32. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  33. package/src/components/gitops/GitOpsView.tsx +81 -14
  34. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  35. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  36. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  37. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  38. package/src/components/home/CostCard.tsx +21 -36
  39. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  40. package/src/components/resource/RightsizingStrip.tsx +319 -123
  41. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  42. package/src/components/rightsizing/copy.test.ts +56 -0
  43. package/src/components/rightsizing/model.test.ts +227 -0
  44. package/src/components/rightsizing/model.ts +158 -0
  45. package/src/components/rightsizing/presentation.test.ts +104 -0
  46. package/src/components/rightsizing/presentation.ts +94 -0
  47. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  48. package/src/components/settings/SettingsDialog.tsx +1268 -318
  49. package/src/components/timeline/TimelineList.tsx +35 -8
  50. package/src/components/timeline/TimelineView.tsx +156 -26
  51. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  52. package/src/components/workload/WorkloadView.tsx +711 -328
  53. package/src/context/DiagnoseCustomization.tsx +36 -2
  54. package/src/index.css +5 -1
  55. package/src/index.ts +4 -1
  56. 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
  }
@@ -361,6 +361,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
361
361
  startRunRef.current(t);
362
362
  }, []);
363
363
  const openRun = useCallback((id: string) => {
364
+ setStartError(null);
364
365
  setActiveRunId(id);
365
366
  setView("investigation");
366
367
  setOpen(true);
@@ -23,6 +23,7 @@ import {
23
23
  agentLabelFor,
24
24
  openDiagnoseSettings,
25
25
  } from "./DiagnoseContext";
26
+ import { useDiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
26
27
  import { InvestigationView } from "./InvestigationView";
27
28
  import { RecentList } from "./Home";
28
29
  import { ConsentCard } from "./parts";
@@ -136,6 +137,7 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
136
137
  // Helm drawers, so it no longer floats viewport-fixed or DOM-measures the chrome.
137
138
  export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
138
139
  const d = useDiagnose();
140
+ const consentCopy = useDiagnoseConsentCopy();
139
141
  const {
140
142
  maximized,
141
143
  setMaximized,
@@ -203,6 +205,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
203
205
  agentName={d.agentLabel}
204
206
  agent={d.selectedAgent}
205
207
  isolated={d.isolated}
208
+ copy={consentCopy}
206
209
  onOpenSettings={openDiagnoseSettings}
207
210
  onApprove={d.approveConsent}
208
211
  onCancel={d.cancelConsent}