@skyhook-io/radar-app 0.2.2 → 0.3.1

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 (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -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
+ }