@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,571 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
3
+ import type { AppRow, AppWorkload } from '@skyhook-io/k8s-ui'
4
+ import {
5
+ COST_DISCOVERY_GRACE_MS,
6
+ useOpenCostApplicationCost,
7
+ useOpenCostApplicationCostTrend,
8
+ type CostTimeRange,
9
+ type CostUnavailableReason,
10
+ type OpenCostApplicationCostResponse,
11
+ type OpenCostApplicationCostTrendResponse,
12
+ type OpenCostApplicationWorkloadCost,
13
+ type OpenCostTrendSeries,
14
+ } from '../../api/client'
15
+ import { Tooltip } from '../ui/Tooltip'
16
+ import { ChartLegend, CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart'
17
+ import {
18
+ formatCostPerHour,
19
+ formatHistoricalSpend,
20
+ formatProjectedDailyRate,
21
+ formatProjectedMonthlyCost,
22
+ formatProjectedMonthlyRate,
23
+ } from './format'
24
+ import { isOpenCostWorkloadKind } from './kinds'
25
+ import { CurrentAllocationUse } from './CurrentAllocationUse'
26
+ import { costUnavailableReasonFromError } from './errors'
27
+
28
+ type ApplicationCostState =
29
+ | 'loading'
30
+ | 'data'
31
+ | 'partial_missing_history'
32
+ | 'partial_missing_current'
33
+ | 'zero'
34
+ | 'load_error'
35
+ | CostUnavailableReason
36
+
37
+ interface ApplicationCostQueryStatus {
38
+ currentLoading?: boolean
39
+ trendLoading?: boolean
40
+ currentError?: unknown
41
+ trendError?: unknown
42
+ }
43
+
44
+ interface ApplicationCostTabProps {
45
+ app: AppRow
46
+ workloads: AppWorkload[]
47
+ onSelectWorkloadCost?: (workload: AppWorkload) => void
48
+ }
49
+
50
+ export function ApplicationCostTab({
51
+ app,
52
+ workloads,
53
+ onSelectWorkloadCost,
54
+ }: ApplicationCostTabProps) {
55
+ const [range, setRange] = useState<CostTimeRange>('24h')
56
+ const [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
57
+ const supportedWorkloads = useMemo(() => applicationCostWorkloads(workloads), [workloads])
58
+ const unsupportedCount = workloads.length - supportedWorkloads.length
59
+ const queriesEnabled = supportedWorkloads.length > 0
60
+ const currentQuery = useOpenCostApplicationCost(supportedWorkloads, {
61
+ enabled: queriesEnabled,
62
+ })
63
+ const trendQuery = useOpenCostApplicationCostTrend(supportedWorkloads, range, {
64
+ enabled: queriesEnabled,
65
+ })
66
+ const trendMatchesRange = trendQuery.data?.range === range
67
+ const trendData = trendMatchesRange ? trendQuery.data : undefined
68
+ const trendLoading =
69
+ trendQuery.isLoading ||
70
+ (trendQuery.isFetching && Boolean(trendQuery.data) && !trendMatchesRange)
71
+ const state = getApplicationCostState(currentQuery.data, trendData, {
72
+ currentLoading: currentQuery.isLoading,
73
+ trendLoading,
74
+ currentError: currentQuery.error,
75
+ trendError: trendQuery.error,
76
+ })
77
+
78
+ useEffect(() => {
79
+ if (state === 'no_prometheus') {
80
+ setNoPrometheusSince((prev) => prev ?? Date.now())
81
+ } else {
82
+ setNoPrometheusSince(null)
83
+ }
84
+ }, [state])
85
+
86
+ const chartSeries = useMemo(() => applicationChartSeries(trendData), [trendData])
87
+ const workloadByKey = useMemo(() => {
88
+ const map = new Map<string, AppWorkload>()
89
+ for (const workload of workloads) map.set(applicationCostKey(workload), workload)
90
+ return map
91
+ }, [workloads])
92
+
93
+ if (supportedWorkloads.length === 0) {
94
+ return (
95
+ <ApplicationCostUnavailable
96
+ state="no_metrics"
97
+ message="No steady-state workloads in this app are currently cost-attributed."
98
+ />
99
+ )
100
+ }
101
+
102
+ if (state === 'loading') {
103
+ return (
104
+ <div className="flex h-full min-h-[320px] items-center justify-center text-theme-text-tertiary">
105
+ <Loader2 className="mr-2 h-5 w-5 animate-spin" />
106
+ Loading application cost…
107
+ </div>
108
+ )
109
+ }
110
+
111
+ if (
112
+ state === 'no_prometheus' ||
113
+ state === 'no_metrics' ||
114
+ state === 'query_error' ||
115
+ state === 'access_denied' ||
116
+ state === 'not_found' ||
117
+ state === 'load_error'
118
+ ) {
119
+ const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
120
+ if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
121
+ return (
122
+ <ApplicationCostDiscovering
123
+ isFetching={currentQuery.isFetching || trendQuery.isFetching}
124
+ onRetry={() => {
125
+ setNoPrometheusSince(Date.now())
126
+ currentQuery.refetch()
127
+ trendQuery.refetch()
128
+ }}
129
+ />
130
+ )
131
+ }
132
+ return <ApplicationCostUnavailable state={state} />
133
+ }
134
+
135
+ const current = currentQuery.data
136
+ const trend = trendData
137
+ const hasCurrent = current?.available === true && (current.coverage?.included ?? 0) > 0
138
+ const totals = hasCurrent ? current?.totals : undefined
139
+ const coverage =
140
+ state === 'partial_missing_current'
141
+ ? (trend?.coverage ?? current?.coverage)
142
+ : (current?.coverage ?? trend?.coverage)
143
+ const included = coverage?.included ?? 0
144
+ const total = (coverage?.total ?? supportedWorkloads.length) + unsupportedCount
145
+ const unavailableCount = coverage?.unavailable?.length ?? 0
146
+ const hourly = totals?.hourlyCost ?? 0
147
+ const points = trend?.available ? (trend.dataPoints ?? []) : []
148
+ const hasTrend = points.length >= 2 && points.some((p) => p.value > 0)
149
+ const rows = current?.workloads ?? []
150
+ const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0)
151
+
152
+ return (
153
+ <div className="mx-auto w-full max-w-[1600px] space-y-4">
154
+ {(current?.partial ||
155
+ trend?.partial ||
156
+ state === 'partial_missing_history' ||
157
+ state === 'partial_missing_current' ||
158
+ unsupportedCount > 0) && (
159
+ <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">
160
+ <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
161
+ <span>
162
+ Showing tracked steady-state compute for {included} of {total} workloads.
163
+ {unavailableCount > 0
164
+ ? ` ${unavailableCount} workload${unavailableCount === 1 ? '' : 's'} could not be included for this window.`
165
+ : ''}
166
+ {unsupportedCount > 0
167
+ ? ` ${unsupportedCount} batch or unsupported workload${unsupportedCount === 1 ? ' is' : 's are'} excluded from this compute view.`
168
+ : ''}
169
+ </span>
170
+ </div>
171
+ )}
172
+
173
+ <section
174
+ className="rounded-lg border border-theme-border bg-theme-surface/50"
175
+ aria-label={`${app.name} application cost`}
176
+ >
177
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-theme-border px-4 py-3">
178
+ <div className="flex items-center gap-2">
179
+ <TrendingUp className="h-4 w-4 text-theme-text-tertiary" />
180
+ <div>
181
+ <div className="flex items-center gap-1.5">
182
+ <div className="text-sm font-semibold text-theme-text-primary">
183
+ Application compute cost
184
+ </div>
185
+ <CostInfoTooltip content="Dollars are based on OpenCost CPU and memory allocation over time, grouped by the workloads in this application. OpenCost allocation uses the greater of requested or observed resources." />
186
+ </div>
187
+ <div className="text-xs text-theme-text-tertiary">
188
+ OpenCost CPU and memory allocation rate ($/hr) for Deployment, StatefulSet, and
189
+ DaemonSet workloads
190
+ </div>
191
+ </div>
192
+ </div>
193
+ <CostTimeRangeSelector value={range} onChange={setRange} />
194
+ </div>
195
+
196
+ <div className="grid gap-4 p-4 lg:grid-cols-[240px_minmax(0,1fr)]">
197
+ <div className="space-y-4">
198
+ <CostMetricBlock
199
+ label={`Spend over ${range}`}
200
+ value={formatHistoricalSpend(
201
+ points.length,
202
+ trend?.windowTotalCost ?? 0,
203
+ trendLoading || state === 'partial_missing_history',
204
+ )}
205
+ subvalue={
206
+ state === 'partial_missing_history'
207
+ ? 'Historical data incomplete'
208
+ : `${included} of ${total} workloads included`
209
+ }
210
+ />
211
+ <CostMetricBlock
212
+ label="Projected monthly"
213
+ value={totals ? formatProjectedMonthlyCost(hourly) : '—'}
214
+ subvalue={
215
+ totals
216
+ ? `${formatCostPerHour(hourly)} current rate`
217
+ : 'Current allocation unavailable'
218
+ }
219
+ />
220
+ </div>
221
+ <div className="min-w-0">
222
+ {trendLoading ? (
223
+ <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">
224
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
225
+ Loading historical cost…
226
+ </div>
227
+ ) : hasTrend && chartSeries.length > 0 ? (
228
+ <div className="min-w-0">
229
+ <StackedAreaChart series={chartSeries} />
230
+ <ChartLegend series={chartSeries} />
231
+ </div>
232
+ ) : (
233
+ <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">
234
+ No historical workload owner cost points for this range.
235
+ </div>
236
+ )}
237
+ </div>
238
+ </div>
239
+ </section>
240
+
241
+ <div className="grid gap-4 md:grid-cols-2">
242
+ <CostMetricTile
243
+ label="Tracked workloads"
244
+ value={`${included}/${total}`}
245
+ subvalue={
246
+ unsupportedCount > 0
247
+ ? `${unsupportedCount} unsupported`
248
+ : 'All supported workloads included'
249
+ }
250
+ />
251
+ <CostMetricTile
252
+ label="Projected daily"
253
+ value={totals ? formatProjectedDailyRate(hourly) : '—'}
254
+ subvalue={
255
+ totals
256
+ ? `${formatCostPerHour(hourly)} current hourly rate`
257
+ : 'Current allocation unavailable'
258
+ }
259
+ />
260
+ </div>
261
+
262
+ <CurrentAllocationUse
263
+ dataAvailable={Boolean(totals)}
264
+ cpuCost={totals?.cpuCost ?? 0}
265
+ memoryCost={totals?.memoryCost ?? 0}
266
+ hourlyCost={hourly}
267
+ cpuAllocationUse={totals?.cpuAllocationUse ?? 0}
268
+ memoryAllocationUse={totals?.memoryAllocationUse ?? 0}
269
+ cpuUsageAvailable={totals?.cpuUsageAvailable ?? false}
270
+ memoryUsageAvailable={totals?.memoryUsageAvailable ?? false}
271
+ scopeNote="Included workloads only"
272
+ />
273
+
274
+ <section className="rounded-lg border border-theme-border bg-theme-surface/50">
275
+ <div className="flex items-center justify-between border-b border-theme-border px-4 py-3">
276
+ <div>
277
+ <div className="text-sm font-semibold text-theme-text-primary">
278
+ Workload contributors
279
+ </div>
280
+ <div className="text-xs text-theme-text-tertiary">
281
+ Projected monthly from current allocation, sorted by spend
282
+ </div>
283
+ </div>
284
+ <div className="text-xs text-theme-text-tertiary">{rows.length} tracked</div>
285
+ </div>
286
+ {rows.length === 0 ? (
287
+ <div className="px-4 py-6 text-sm text-theme-text-tertiary">
288
+ No workload cost rows available for this application.
289
+ </div>
290
+ ) : (
291
+ <div className="divide-y divide-theme-border/60">
292
+ {rows.map((row) => {
293
+ const appWorkload = workloadByKey.get(applicationCostKey(row))
294
+ return (
295
+ <ApplicationWorkloadCostRow
296
+ key={applicationCostKey(row)}
297
+ row={row}
298
+ maxCost={maxCost}
299
+ onOpen={
300
+ appWorkload && onSelectWorkloadCost
301
+ ? () => onSelectWorkloadCost(appWorkload)
302
+ : undefined
303
+ }
304
+ />
305
+ )
306
+ })}
307
+ </div>
308
+ )}
309
+ </section>
310
+
311
+ <div className="text-xs text-theme-text-tertiary">
312
+ Powered by OpenCost via Prometheus. Historical spend uses the selected range; projected
313
+ monthly values multiply current hourly allocation. Batch/job cost is separate; storage/PVC
314
+ and network costs remain at namespace and cluster level.
315
+ </div>
316
+ </div>
317
+ )
318
+ }
319
+
320
+ export function getApplicationCostState(
321
+ current: OpenCostApplicationCostResponse | undefined,
322
+ trend: OpenCostApplicationCostTrendResponse | undefined,
323
+ status: ApplicationCostQueryStatus,
324
+ ): ApplicationCostState {
325
+ const loading = Boolean(status.currentLoading || status.trendLoading)
326
+ const queryError = Boolean(status.currentError || status.trendError)
327
+ const currentHasData = current?.available === true && (current.coverage?.included ?? 0) > 0
328
+ const trendHasData =
329
+ trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0)
330
+ if (currentHasData) {
331
+ if (status.trendLoading && !trend) return 'data'
332
+ if (status.trendError || (trend?.available === false && trend.reason !== 'no_metrics'))
333
+ return 'partial_missing_history'
334
+ if ((current?.totals?.hourlyCost ?? 0) === 0 && !trendHasData) return 'zero'
335
+ if (!trend?.available) return 'partial_missing_history'
336
+ return 'data'
337
+ }
338
+ if (trendHasData) return 'partial_missing_current'
339
+ const reason =
340
+ current?.reason ??
341
+ trend?.reason ??
342
+ costUnavailableReasonFromError(status.currentError) ??
343
+ costUnavailableReasonFromError(status.trendError)
344
+ if (
345
+ reason === 'no_prometheus' ||
346
+ reason === 'query_error' ||
347
+ reason === 'access_denied' ||
348
+ reason === 'not_found'
349
+ )
350
+ return reason
351
+ if (queryError) return 'load_error'
352
+ if (loading) return 'loading'
353
+
354
+ return 'no_metrics'
355
+ }
356
+
357
+ export function applicationCostWorkloads(workloads: AppWorkload[]): AppWorkload[] {
358
+ return workloads.filter((workload) => isOpenCostWorkloadKind(workload.kind))
359
+ }
360
+
361
+ function ApplicationWorkloadCostRow({
362
+ row,
363
+ maxCost,
364
+ onOpen,
365
+ }: {
366
+ row: OpenCostApplicationWorkloadCost
367
+ maxCost: number
368
+ onOpen?: () => void
369
+ }) {
370
+ const current = row.current
371
+ const hourly = current?.hourlyCost ?? 0
372
+ const cpuPct = hourly > 0 ? ((current?.cpuCost ?? 0) / hourly) * 100 : 0
373
+ const barWidth = maxCost > 0 ? Math.max((hourly / maxCost) * 100, 3) : 0
374
+ const content = (
375
+ <>
376
+ <div className="min-w-0">
377
+ <div className="flex min-w-0 items-center gap-2">
378
+ <span className="shrink-0 rounded bg-theme-base px-1.5 py-0.5 text-[10px] uppercase text-theme-text-tertiary">
379
+ {row.kind}
380
+ </span>
381
+ <Tooltip content={`${row.kind} ${row.namespace}/${row.name}`} wrapperClassName="min-w-0">
382
+ <span className="block truncate text-sm font-medium text-theme-text-primary">
383
+ {row.name}
384
+ </span>
385
+ </Tooltip>
386
+ <Tooltip content={row.namespace} wrapperClassName="shrink-0">
387
+ <span className="text-xs text-theme-text-tertiary">{row.namespace}</span>
388
+ </Tooltip>
389
+ </div>
390
+ {!row.available && (
391
+ <div className="mt-0.5 text-xs text-theme-text-tertiary">{reasonLabel(row.reason)}</div>
392
+ )}
393
+ </div>
394
+ <div className="text-right text-sm font-medium tabular-nums text-theme-text-primary">
395
+ {current ? formatProjectedMonthlyRate(hourly) : '—'}
396
+ </div>
397
+ <div className="hidden text-right text-xs tabular-nums text-theme-text-tertiary sm:block">
398
+ {current ? formatCostPerHour(hourly) : '—'}
399
+ </div>
400
+ <div className="hidden min-w-0 items-center gap-2 md:flex">
401
+ <div
402
+ className="h-1.5 flex-1 overflow-hidden rounded-full bg-theme-hover"
403
+ style={{ maxWidth: `${barWidth}%` }}
404
+ >
405
+ <div className="flex h-full">
406
+ <div className="h-full bg-accent" style={{ width: `${cpuPct}%` }} />
407
+ <div className="h-full bg-amber-500" style={{ width: `${100 - cpuPct}%` }} />
408
+ </div>
409
+ </div>
410
+ </div>
411
+ <div className="hidden text-right text-xs tabular-nums text-theme-text-tertiary lg:block">
412
+ {current
413
+ ? `${formatProjectedMonthlyCost(current.cpuCost)} / ${formatProjectedMonthlyCost(current.memoryCost)}`
414
+ : '—'}
415
+ </div>
416
+ </>
417
+ )
418
+
419
+ if (!onOpen) {
420
+ return (
421
+ <div className="grid grid-cols-[minmax(180px,1fr)_100px] gap-3 px-4 py-3 sm:grid-cols-[minmax(180px,1fr)_100px_100px] md:grid-cols-[minmax(220px,1fr)_100px_100px_minmax(160px,1fr)] lg:grid-cols-[minmax(220px,1fr)_110px_110px_minmax(180px,1fr)_130px]">
422
+ {content}
423
+ </div>
424
+ )
425
+ }
426
+ return (
427
+ <button
428
+ type="button"
429
+ onClick={onOpen}
430
+ className="grid w-full grid-cols-[minmax(180px,1fr)_100px] gap-3 px-4 py-3 text-left transition-colors hover:bg-theme-hover sm:grid-cols-[minmax(180px,1fr)_100px_100px] md:grid-cols-[minmax(220px,1fr)_100px_100px_minmax(160px,1fr)] lg:grid-cols-[minmax(220px,1fr)_110px_110px_minmax(180px,1fr)_130px]"
431
+ >
432
+ {content}
433
+ </button>
434
+ )
435
+ }
436
+
437
+ function applicationChartSeries(
438
+ trend: OpenCostApplicationCostTrendResponse | undefined,
439
+ ): OpenCostTrendSeries[] {
440
+ return (trend?.series ?? [])
441
+ .filter((series) => (series.dataPoints ?? []).length >= 2)
442
+ .map((series) => ({
443
+ namespace: `${series.name} (${series.kind})`,
444
+ dataPoints: series.dataPoints ?? [],
445
+ }))
446
+ }
447
+
448
+ function applicationCostKey(ref: { kind: string; namespace: string; name: string }) {
449
+ return `${ref.kind}/${ref.namespace}/${ref.name}`
450
+ }
451
+
452
+ function reasonLabel(reason?: CostUnavailableReason) {
453
+ if (reason === 'no_prometheus') return 'Prometheus not found'
454
+ if (reason === 'query_error') return 'Cost query failed'
455
+ if (reason === 'access_denied') return 'No access to this workload'
456
+ if (reason === 'not_found') return 'Workload not found'
457
+ return 'No workload cost metrics'
458
+ }
459
+
460
+ function ApplicationCostDiscovering({
461
+ isFetching,
462
+ onRetry,
463
+ }: {
464
+ isFetching: boolean
465
+ onRetry: () => void
466
+ }) {
467
+ return (
468
+ <div className="flex h-full min-h-[320px] items-center justify-center">
469
+ <div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
470
+ <Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary/60" />
471
+ <div>
472
+ <p className="text-sm font-medium text-theme-text-primary">
473
+ Looking for Prometheus cost data…
474
+ </p>
475
+ <p className="mt-1 text-xs text-theme-text-tertiary">
476
+ First discovery can take a few seconds while Radar checks cluster services and opens a
477
+ local port-forward.
478
+ </p>
479
+ </div>
480
+ <button
481
+ onClick={onRetry}
482
+ disabled={isFetching}
483
+ className="text-xs text-accent-text transition-colors hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled"
484
+ >
485
+ {isFetching ? 'Checking…' : 'Check again'}
486
+ </button>
487
+ </div>
488
+ </div>
489
+ )
490
+ }
491
+
492
+ function ApplicationCostUnavailable({
493
+ state,
494
+ message,
495
+ }: {
496
+ state: CostUnavailableReason | 'load_error'
497
+ message?: string
498
+ }) {
499
+ const text =
500
+ message ??
501
+ (state === 'no_prometheus'
502
+ ? 'Prometheus not found. OpenCost application cost requires Prometheus or VictoriaMetrics.'
503
+ : state === 'query_error'
504
+ ? 'Cost data is temporarily unavailable. Prometheus was found, but application cost queries failed.'
505
+ : state === 'access_denied'
506
+ ? 'Cost data is unavailable because these workloads are not accessible with your current permissions.'
507
+ : state === 'not_found'
508
+ ? 'Cost data is unavailable because the referenced workloads no longer exist.'
509
+ : state === 'load_error'
510
+ ? 'Could not load application cost data. Check access to these workloads and try again.'
511
+ : 'OpenCost workload metrics were not found for this application.')
512
+ return (
513
+ <div className="flex h-full min-h-[320px] items-center justify-center">
514
+ <div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
515
+ <DollarSign className="h-8 w-8 text-theme-text-tertiary/50" />
516
+ <div className="text-sm">{text}</div>
517
+ </div>
518
+ </div>
519
+ )
520
+ }
521
+
522
+ function CostMetricBlock({
523
+ label,
524
+ value,
525
+ subvalue,
526
+ }: {
527
+ label: string
528
+ value: string
529
+ subvalue?: string
530
+ }) {
531
+ return (
532
+ <div>
533
+ <div className="text-xs font-medium uppercase text-theme-text-tertiary">{label}</div>
534
+ <div className="mt-1 text-2xl font-semibold text-theme-text-primary tabular-nums">
535
+ {value}
536
+ </div>
537
+ {subvalue && <div className="mt-1 text-xs text-theme-text-tertiary">{subvalue}</div>}
538
+ </div>
539
+ )
540
+ }
541
+
542
+ function CostMetricTile({
543
+ label,
544
+ value,
545
+ subvalue,
546
+ tooltip,
547
+ }: {
548
+ label: string
549
+ value: string
550
+ subvalue?: string
551
+ tooltip?: string
552
+ }) {
553
+ return (
554
+ <div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
555
+ <div className="flex items-center gap-1.5">
556
+ <div className="text-xs font-medium uppercase text-theme-text-tertiary">{label}</div>
557
+ {tooltip && <CostInfoTooltip content={tooltip} />}
558
+ </div>
559
+ <div className="mt-1 text-lg font-semibold text-theme-text-primary tabular-nums">{value}</div>
560
+ {subvalue && <div className="mt-1 text-xs text-theme-text-tertiary">{subvalue}</div>}
561
+ </div>
562
+ )
563
+ }
564
+
565
+ function CostInfoTooltip({ content }: { content: string }) {
566
+ return (
567
+ <Tooltip content={content} className="max-w-[280px] whitespace-normal text-left" delay={150}>
568
+ <HelpCircle className="h-3.5 w-3.5 cursor-help text-theme-text-tertiary transition-colors hover:text-theme-text-secondary" />
569
+ </Tooltip>
570
+ )
571
+ }