@skyhook-io/radar-app 1.12.2 → 1.13.0

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 (60) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +35 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +246 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  16. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  17. package/src/components/cost/CostTrendChart.test.ts +65 -0
  18. package/src/components/cost/CostTrendChart.tsx +107 -17
  19. package/src/components/cost/CostView.test.ts +36 -1
  20. package/src/components/cost/CostView.tsx +133 -51
  21. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  22. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  23. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  24. package/src/components/cost/source.test.ts +33 -0
  25. package/src/components/cost/source.ts +100 -0
  26. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  27. package/src/components/diagnose/parts.test.tsx +12 -4
  28. package/src/components/diagnose/parts.tsx +19 -15
  29. package/src/components/gitops/GitOpsView.tsx +8 -3
  30. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  31. package/src/components/helm/OwnedResources.tsx +10 -2
  32. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  33. package/src/components/home/ClusterHealthCard.tsx +60 -1
  34. package/src/components/home/CostCard.tsx +3 -2
  35. package/src/components/home/HomeView.tsx +36 -11
  36. package/src/components/home/MCPSetupDialog.tsx +5 -4
  37. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  38. package/src/components/home/RadarVersionLine.tsx +137 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  40. package/src/components/resources/ResourcesView.tsx +31 -8
  41. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  42. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  43. package/src/components/rightsizing/copy.test.ts +19 -0
  44. package/src/components/settings/SettingsDialog.tsx +687 -107
  45. package/src/components/settings/settings-state.test.ts +42 -0
  46. package/src/components/settings/settings-state.ts +39 -0
  47. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  48. package/src/components/ui/ErrorBoundary.tsx +17 -2
  49. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  50. package/src/components/ui/UpdateNotification.tsx +6 -2
  51. package/src/components/workload/WorkloadView.test.ts +60 -0
  52. package/src/components/workload/WorkloadView.tsx +270 -29
  53. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  54. package/src/contexts/CapabilitiesContext.tsx +7 -3
  55. package/src/index.css +11 -1
  56. package/src/utils/navigation.test.ts +45 -0
  57. package/src/utils/navigation.ts +5 -5
  58. package/src/utils/topology-selection.ts +3 -2
  59. package/src/utils/version.test.ts +37 -0
  60. package/src/utils/version.ts +56 -0
@@ -8,6 +8,7 @@ import {
8
8
  useOpenCostNodes,
9
9
  } from '../../api/client'
10
10
  import type {
11
+ CostUnavailableReason,
11
12
  OpenCostNamespaceCost,
12
13
  OpenCostWorkloadCost,
13
14
  OpenCostNodeCost,
@@ -39,6 +40,15 @@ import { kindToPlural, openExternal } from '../../utils/navigation'
39
40
  import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console'
40
41
  import { RightsizingScanView } from '../rightsizing/RightsizingScanView'
41
42
  import { CostViewTabs } from './CostViewTabs'
43
+ import { useNavCustomization } from '../../context/NavCustomization'
44
+ import {
45
+ costConfigurationAction,
46
+ costFreshnessLabel,
47
+ costIntegrationUnavailableMessage,
48
+ costRateLabels,
49
+ costSourceLabel,
50
+ isCostDiscoveryPending,
51
+ } from './source'
42
52
 
43
53
  interface CostViewProps {
44
54
  onBack: () => void
@@ -47,6 +57,13 @@ interface CostViewProps {
47
57
  }
48
58
 
49
59
  const SYSTEM_COST_NAMESPACES = new Set(['kube-system', 'kube-public', 'kube-node-lease'])
60
+ const CONFIGURABLE_COST_REASONS = new Set<CostUnavailableReason>([
61
+ 'no_prometheus',
62
+ 'no_cost_source',
63
+ 'source_unavailable',
64
+ 'authentication_error',
65
+ 'configuration_mismatch',
66
+ ])
50
67
 
51
68
  export function CostView(props: CostViewProps) {
52
69
  const { pathname } = useLocation()
@@ -61,14 +78,17 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
61
78
  const { data: nodeData } = useOpenCostNodes()
62
79
  const { data: clusterInfo } = useClusterInfo()
63
80
  const { connection } = useConnection()
81
+ const navCustomization = useNavCustomization()
82
+ const settingsAvailable = !navCustomization.embedded
64
83
  const [showHelp, setShowHelp] = useState(false)
65
- const [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
84
+ const [discoverySince, setDiscoverySince] = useState<number | null>(null)
85
+ const namespaceScopeCount = data?.namespaceScope?.length ?? 0
66
86
 
67
87
  useEffect(() => {
68
- if (data?.available === false && data.reason === 'no_prometheus') {
69
- setNoPrometheusSince((prev) => prev ?? Date.now())
88
+ if (data?.available === false && isCostDiscoveryPending(data.reason)) {
89
+ setDiscoverySince((prev) => prev ?? Date.now())
70
90
  } else {
71
- setNoPrometheusSince(null)
91
+ setDiscoverySince(null)
72
92
  }
73
93
  }, [data?.available, data?.reason])
74
94
 
@@ -82,8 +102,8 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
82
102
 
83
103
  if (!data || !data.available) {
84
104
  const reason = data?.reason
85
- const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
86
- if (reason === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
105
+ const discoveryAgeMs = discoverySince == null ? 0 : Date.now() - discoverySince
106
+ if (isCostDiscoveryPending(reason) && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
87
107
  return (
88
108
  <CostOverviewState>
89
109
  <div className="flex min-h-64 items-center justify-center">
@@ -91,16 +111,16 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
91
111
  <Loader2 className="w-8 h-8 animate-spin text-theme-text-tertiary/60" />
92
112
  <div>
93
113
  <p className="text-sm font-medium text-theme-text-primary">
94
- Looking for Prometheus cost data…
114
+ Looking for cost data…
95
115
  </p>
96
116
  <p className="mt-1 text-xs text-theme-text-tertiary">
97
- First discovery can take a few seconds while Radar checks cluster services and
98
- opens a local port-forward.
117
+ Radar is checking OpenCost metrics in a PromQL-compatible backend and a local
118
+ Kubecost 3 Aggregator. First discovery can take a few seconds.
99
119
  </p>
100
120
  </div>
101
121
  <button
102
122
  onClick={() => {
103
- setNoPrometheusSince(Date.now())
123
+ setDiscoverySince(Date.now())
104
124
  refetch()
105
125
  }}
106
126
  disabled={isFetching}
@@ -113,14 +133,12 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
113
133
  </CostOverviewState>
114
134
  )
115
135
  }
116
- const message =
117
- reason === 'no_prometheus'
118
- ? 'Prometheus not found — OpenCost requires Prometheus or VictoriaMetrics'
119
- : reason === 'no_metrics'
120
- ? 'OpenCost metrics not found Prometheus is available but no cost metrics were detected'
121
- : reason === 'query_error'
122
- ? 'Cost data temporarily unavailable — Prometheus was found but queries failed'
123
- : 'OpenCost not detected — install OpenCost for cost visibility'
136
+ const message = costUnavailableMessage(reason, {
137
+ settingsAvailable,
138
+ namespaceScoped: namespaceScopeCount > 0,
139
+ })
140
+ const canConfigure = settingsAvailable && reason != null && CONFIGURABLE_COST_REASONS.has(reason)
141
+ const configureAction = costConfigurationAction(reason)
124
142
 
125
143
  return (
126
144
  <CostOverviewState>
@@ -128,24 +146,37 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
128
146
  <div className="flex flex-col items-center gap-3 text-theme-text-secondary">
129
147
  <Coins className="w-8 h-8 text-theme-text-tertiary/40" />
130
148
  <p className="text-sm">{message}</p>
131
- <button
132
- onClick={onBack}
133
- className="text-xs text-skyhook-400 hover:text-skyhook-300 transition-colors"
134
- >
135
- Back to Dashboard
136
- </button>
137
- {reason === 'no_prometheus' && (
149
+ <div className="flex flex-wrap items-center justify-center gap-2">
150
+ {canConfigure && (
151
+ <button
152
+ type="button"
153
+ onClick={() => window.dispatchEvent(
154
+ new CustomEvent('radar:open-settings', { detail: { section: configureAction.section } }),
155
+ )}
156
+ className="btn-brand px-3 py-1.5 text-xs font-medium"
157
+ >
158
+ {configureAction.label}
159
+ </button>
160
+ )}
161
+ {(reason === 'no_prometheus' || reason === 'no_cost_source' || reason === 'source_unavailable') && (
138
162
  <button
139
163
  onClick={() => {
140
- setNoPrometheusSince(Date.now())
164
+ setDiscoverySince(Date.now())
141
165
  refetch()
142
166
  }}
143
167
  disabled={isFetching}
144
- className="text-xs text-accent-text hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled transition-colors"
168
+ className="rounded-md border border-theme-border px-3 py-1.5 text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled"
145
169
  >
146
170
  {isFetching ? 'Checking…' : 'Check again'}
147
171
  </button>
148
172
  )}
173
+ </div>
174
+ <button
175
+ onClick={onBack}
176
+ className="text-xs text-accent-text hover:text-theme-text-primary transition-colors"
177
+ >
178
+ Back to Dashboard
179
+ </button>
149
180
  </div>
150
181
  </div>
151
182
  </CostOverviewState>
@@ -154,6 +185,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
154
185
 
155
186
  const hourlyCost = data.totalHourlyCost ?? 0
156
187
  const currency = data.currency ?? DEFAULT_COST_CURRENCY
188
+ const rateLabels = costRateLabels(data.source === 'kubecost' ? data.window : undefined)
157
189
  const namespaces = data.namespaces ?? []
158
190
  const totalCpu = namespaces.reduce((sum, ns) => sum + ns.cpuCost, 0)
159
191
  const totalMem = namespaces.reduce((sum, ns) => sum + ns.memoryCost, 0)
@@ -213,6 +245,9 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
213
245
  connectionState={connection.state}
214
246
  />
215
247
  <div className="flex flex-col items-end">
248
+ <span className="text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">
249
+ Allocated workload cost
250
+ </span>
216
251
  <div className="flex items-baseline gap-1">
217
252
  <span className="text-2xl font-bold text-theme-text-primary tabular-nums">
218
253
  {formatProjectedMonthlyCost(hourlyCost, currency)}
@@ -229,7 +264,9 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
229
264
  </span>
230
265
  </div>
231
266
  <span className="text-[10px] text-theme-text-quaternary">
232
- projected from last 1h average
267
+ {data.source === 'kubecost'
268
+ ? costFreshnessLabel(data.source, data.window, data.dataThrough)
269
+ : `projected from ${costFreshnessLabel(data.source, data.window)}`}
233
270
  </span>
234
271
  </div>
235
272
  </div>
@@ -237,11 +274,19 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
237
274
 
238
275
  <CostViewTabs />
239
276
 
277
+ {namespaceScopeCount > 0 && (
278
+ <div className="rounded-lg border border-theme-border bg-theme-surface/50 px-4 py-3 text-xs text-theme-text-secondary">
279
+ Allocated workload cost, trend, and namespace breakdown are scoped to {namespaceScopeCount}{' '}
280
+ {namespaceScopeCount === 1 ? 'namespace' : 'namespaces'}.
281
+ {nodes.length > 0 && ' Node costs below remain cluster-wide.'}
282
+ </div>
283
+ )}
284
+
240
285
  {/* CPU vs Memory (vs Storage) split bar */}
241
286
  <div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
242
287
  <div className="flex items-center justify-between mb-2">
243
288
  <span className="text-xs font-medium text-theme-text-secondary">
244
- Cluster Resource Cost
289
+ {namespaceScopeCount > 0 ? 'Scoped allocated workload cost' : 'Allocated workload cost'}
245
290
  </span>
246
291
  <div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
247
292
  <span className="flex items-center gap-1.5">
@@ -279,7 +324,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
279
324
  </div>
280
325
 
281
326
  {/* Cost trend chart */}
282
- <CostTrendChart />
327
+ <CostTrendChart namespaceScoped={namespaceScopeCount > 0} />
283
328
 
284
329
  {/* Namespace cost table */}
285
330
  <div className="rounded-lg border border-theme-border bg-theme-surface/50">
@@ -290,7 +335,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
290
335
  Namespace Breakdown
291
336
  </span>
292
337
  <span className="text-[10px] text-theme-text-quaternary ml-2">
293
- projected monthly from current rate
338
+ projected monthly from {rateLabels.rate}
294
339
  </span>
295
340
  </div>
296
341
  <span className="text-xs text-theme-text-tertiary">
@@ -304,7 +349,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
304
349
  <div className="grid grid-cols-[minmax(180px,1fr)_110px_90px_minmax(160px,1fr)_150px] gap-2 px-4 py-2 border-b border-theme-border text-[11px] font-medium text-theme-text-tertiary uppercase tracking-wider">
305
350
  <span>Namespace</span>
306
351
  <Tooltip
307
- content="Projected from current hourly rate — not historical spend"
352
+ content={`Projected from ${rateLabels.rate} — not historical spend`}
308
353
  wrapperClassName="!block text-right"
309
354
  >
310
355
  <span className="cursor-help">Projected/mo*</span>
@@ -312,7 +357,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
312
357
  <span className="text-right">Hourly</span>
313
358
  <span>CPU / Memory</span>
314
359
  <Tooltip
315
- content="Projected monthly CPU and memory allocation from the current hourly rate"
360
+ content={`Projected monthly CPU and memory allocation from the ${rateLabels.rate}`}
316
361
  wrapperClassName="!block text-right"
317
362
  >
318
363
  <span className="cursor-help">CPU / Memory/mo*</span>
@@ -346,22 +391,46 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
346
391
  {/* Footer */}
347
392
  <div className="flex items-center justify-between text-xs text-theme-text-tertiary pb-4">
348
393
  <span>
349
- {currency} &middot; current rates based on last 1h average &middot;
394
+ {currency} &middot; {costFreshnessLabel(data.source, data.window, data.dataThrough)} &middot;
350
395
  *monthly projections assume {COST_HOURS_PER_MONTH} hrs/mo
351
396
  {currency !== DEFAULT_COST_CURRENCY && (
352
397
  <> &middot; no conversion</>
353
398
  )}
354
399
  </span>
355
- <span className="text-indigo-500 font-medium">Powered by OpenCost</span>
400
+ <span className="text-indigo-500 font-medium">{costSourceLabel(data.source)}</span>
356
401
  </div>
357
402
  </div>
358
403
 
359
404
  {/* Help dialog */}
360
- {showHelp && <CostHelpDialog currency={currency} onClose={() => setShowHelp(false)} />}
405
+ {showHelp && <CostHelpDialog currency={currency} source={data.source} window={data.window} onClose={() => setShowHelp(false)} />}
361
406
  </div>
362
407
  )
363
408
  }
364
409
 
410
+ export function costUnavailableMessage(
411
+ reason?: CostUnavailableReason,
412
+ options: { settingsAvailable?: boolean; namespaceScoped?: boolean } = {},
413
+ ): string {
414
+ if (reason === 'no_metrics' && options.namespaceScoped) {
415
+ return 'No allocation data is visible in the current namespace scope'
416
+ }
417
+ const integrationMessage = costIntegrationUnavailableMessage(reason, options.settingsAvailable ?? true)
418
+ if (integrationMessage) return integrationMessage
419
+
420
+ switch (reason) {
421
+ case 'no_prometheus':
422
+ return 'No compatible cost source found — connect OpenCost metrics through a PromQL-compatible backend or configure Kubecost'
423
+ case 'no_metrics':
424
+ return 'Cost data is not ready — the selected source returned no allocation data'
425
+ case 'query_error':
426
+ return 'Cost data is temporarily unavailable — the selected source query failed'
427
+ case 'access_denied':
428
+ return 'You do not have access to view cluster cost data'
429
+ default:
430
+ return 'No compatible cost data was detected'
431
+ }
432
+ }
433
+
365
434
  function CostOverviewState({ children }: { children: React.ReactNode }) {
366
435
  return (
367
436
  <div className="flex-1 overflow-y-auto">
@@ -618,6 +687,7 @@ function NodeCostTable({
618
687
  currency: string
619
688
  onOpenResource?: (resource: SelectedResource) => void
620
689
  }) {
690
+ const totalHourlyCost = nodes.reduce((total, node) => total + node.hourlyCost, 0)
621
691
  return (
622
692
  <div className="rounded-lg border border-theme-border bg-theme-surface/50">
623
693
  <div className="px-4 py-3 border-b border-theme-border">
@@ -625,14 +695,21 @@ function NodeCostTable({
625
695
  <div>
626
696
  <div className="flex items-center gap-2">
627
697
  <Server className="w-4 h-4 text-theme-text-tertiary" />
628
- <span className="text-sm font-semibold text-theme-text-primary">Node Costs</span>
698
+ <span className="text-sm font-semibold text-theme-text-primary">Cluster-wide node capacity cost</span>
629
699
  <span className="text-[10px] text-theme-text-quaternary">current pricing</span>
630
700
  </div>
631
701
  <p className="text-[11px] text-theme-text-tertiary mt-0.5 ml-6">
632
702
  Per-machine cloud pricing — namespace costs above show how this capacity is allocated
633
703
  </p>
634
704
  </div>
635
- <span className="text-xs text-theme-text-tertiary">{nodes.length} nodes</span>
705
+ <div className="text-right">
706
+ <div className="text-sm font-semibold tabular-nums text-theme-text-primary">
707
+ {formatProjectedMonthlyCost(totalHourlyCost, currency)}<span className="ml-1 text-[10px] font-normal text-theme-text-tertiary">/mo</span>
708
+ </div>
709
+ <span className="text-xs text-theme-text-tertiary">
710
+ {nodes.length} {nodes.length === 1 ? 'node' : 'nodes'} · current capacity
711
+ </span>
712
+ </div>
636
713
  </div>
637
714
  </div>
638
715
 
@@ -768,7 +845,8 @@ function apiGroupForCostWorkload(kind: string): string | undefined {
768
845
 
769
846
  // --- Help dialog ---
770
847
 
771
- function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () => void }) {
848
+ function CostHelpDialog({ currency, source, window, onClose }: { currency: string; source?: 'prometheus' | 'kubecost'; window?: string; onClose: () => void }) {
849
+ const rateLabels = costRateLabels(source === 'kubecost' ? window : undefined)
772
850
  useEffect(() => {
773
851
  const handleKeyDown = (e: KeyboardEvent) => {
774
852
  if (e.key === 'Escape') onClose()
@@ -804,9 +882,9 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () =
804
882
  Where do these costs come from?
805
883
  </h3>
806
884
  <p>
807
- Cost data comes from <strong>OpenCost</strong>, an open-source tool that combines your
808
- cloud provider's pricing (how much each node costs per hour) with Kubernetes resource
809
- allocation data. This gives you a cost value for each workload running on your cluster.
885
+ Radar reads either OpenCost-compatible metrics from Prometheus or allocation
886
+ and asset data from a Kubecost 3 Aggregator. This view is currently using{' '}
887
+ <strong>{costSourceLabel(source)}</strong>.
810
888
  </p>
811
889
  </section>
812
890
 
@@ -816,9 +894,11 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () =
816
894
  </h3>
817
895
  <p>
818
896
  Radar labels these values <strong>{currency}</strong> and does not convert them. Auto
819
- reads <code>currencyCode</code> or <code>DISPLAY_CURRENCY</code> from an active
820
- OpenCost/Kubecost installation when Prometheus is cluster-discovered, then falls back
821
- to USD. Override it in <strong>Settings Cost</strong> or, for automation, with{' '}
897
+ reads <code>currencyCode</code> or <code>DISPLAY_CURRENCY</code> from a
898
+ cluster-discovered OpenCost installation or any active Kubecost installation, then
899
+ falls back to USD. A manually configured Prometheus URL disables OpenCost currency
900
+ detection.
901
+ Override it in <strong>Settings → Cost</strong> or, for automation, with{' '}
822
902
  <code>--opencost-currency</code> (Helm: <code>cost.currency</code>).
823
903
  </p>
824
904
  </section>
@@ -834,7 +914,7 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () =
834
914
  useful for attribution, but it is not a direct measurement of request headroom.
835
915
  </p>
836
916
  <p className="mt-1.5">
837
- Projected monthly and daily numbers multiply the current hourly allocation rate. They
917
+ Projected monthly and daily numbers multiply the {rateLabels.rate}. They
838
918
  are useful for budget impact, but they are not a historical invoice total. Historical
839
919
  spend on application and workload tabs uses the selected range.
840
920
  </p>
@@ -846,13 +926,15 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () =
846
926
  How fresh is this data?
847
927
  </h3>
848
928
  <p>
849
- Cost rates and breakdowns are <strong>snapshots based on the last 1 hour</strong> of
850
- data. They update automatically every minute. The trend chart shows historical hourly
851
- allocation rate over the selected time range (6 hours, 24 hours, or 7 days).
929
+ {source === 'kubecost' ? (
930
+ <>Allocation rates use the latest completed Kubecost ETL window and show its data-through time. The cluster trend shows the allocation history Kubecost retained for the selected range; workload and application history are not available through this integration.</>
931
+ ) : (
932
+ <>Cost rates and breakdowns are <strong>snapshots based on the last 1 hour</strong> of data. They update automatically every minute. The trend chart shows historical hourly allocation rate over the selected time range.</>
933
+ )}
852
934
  </p>
853
935
  <p className="mt-1.5">
854
- Because costs are based on a 1-hour window, short-lived spikes or dips may not be
855
- reflected. The trend chart gives you the longer-term rate picture.
936
+ Short-lived spikes or dips may not be reflected in the {rateLabels.rate}. Projected daily
937
+ and monthly values are estimates, not invoice totals.
856
938
  </p>
857
939
  </section>
858
940
 
@@ -2,6 +2,7 @@ import { clsx } from 'clsx'
2
2
  import { HelpCircle } from 'lucide-react'
3
3
  import { Tooltip } from '../ui/Tooltip'
4
4
  import { formatCostPerHour, formatProjectedMonthlyRate } from './format'
5
+ import { costRateLabels } from './source'
5
6
 
6
7
  interface CurrentAllocationUseProps {
7
8
  currency: string
@@ -14,10 +15,11 @@ interface CurrentAllocationUseProps {
14
15
  cpuUsageAvailable: boolean
15
16
  memoryUsageAvailable: boolean
16
17
  scopeNote?: string
18
+ window?: string
17
19
  }
18
20
 
19
21
  export const ALLOCATION_USE_TOOLTIP =
20
- 'OpenCost allocation uses the greater of requested or observed CPU and memory. The percentages compare observed use with that allocated amount; they are not request headroom or a right-sizing recommendation.'
22
+ 'Cost allocation uses the greater of requested or observed CPU and memory. The percentages compare observed use with that allocated amount; they are not request headroom or a right-sizing recommendation.'
21
23
 
22
24
  export function formatAllocatedUse(
23
25
  allocationCost: number,
@@ -43,23 +45,25 @@ export function CurrentAllocationUse({
43
45
  cpuUsageAvailable,
44
46
  memoryUsageAvailable,
45
47
  scopeNote,
48
+ window,
46
49
  }: CurrentAllocationUseProps) {
47
50
  const splitTotal = cpuCost + memoryCost
48
51
  const cpuPct = splitTotal > 0 ? (cpuCost / splitTotal) * 100 : 0
49
52
  const memoryPct = splitTotal > 0 ? (memoryCost / splitTotal) * 100 : 0
53
+ const labels = costRateLabels(window)
50
54
 
51
55
  return (
52
56
  <section className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
53
57
  <div className="mb-3 flex items-center justify-between gap-3">
54
58
  <div>
55
59
  <div className="flex items-center gap-1.5">
56
- <div className="text-sm font-semibold text-theme-text-primary">Current allocation and use</div>
60
+ <div className="text-sm font-semibold text-theme-text-primary">{labels.allocationTitle}</div>
57
61
  <Tooltip content={ALLOCATION_USE_TOOLTIP} className="max-w-[320px] whitespace-normal text-left" delay={150}>
58
62
  <HelpCircle className="h-3.5 w-3.5 cursor-help text-theme-text-tertiary transition-colors hover:text-theme-text-secondary" />
59
63
  </Tooltip>
60
64
  </div>
61
65
  <div className="text-xs text-theme-text-tertiary">
62
- Allocation and CPU use: 1h average · Memory use: current
66
+ {labels.allocationPeriod}
63
67
  {scopeNote ? ` · ${scopeNote}` : ''}
64
68
  </div>
65
69
  </div>
@@ -69,7 +73,7 @@ export function CurrentAllocationUse({
69
73
  </div>
70
74
  {dataAvailable && (
71
75
  <div className="text-[10px] text-theme-text-tertiary tabular-nums">
72
- {formatCostPerHour(hourlyCost, currency)} current rate
76
+ {formatCostPerHour(hourlyCost, currency)} · {labels.rate}
73
77
  </div>
74
78
  )}
75
79
  </div>
@@ -69,6 +69,53 @@ describe('getWorkloadCostState', () => {
69
69
  expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_history')
70
70
  })
71
71
 
72
+ it('keeps Kubecost current cost visible when history is unsupported', () => {
73
+ const current: OpenCostWorkloadDetailResponse = {
74
+ available: true,
75
+ source: 'kubecost',
76
+ currency: 'USD',
77
+ namespace: 'default',
78
+ kind: 'StatefulSet',
79
+ name: 'queue',
80
+ current: {
81
+ name: 'queue', kind: 'StatefulSet', hourlyCost: 0.2, cpuCost: 0.12,
82
+ memoryCost: 0.08, replicas: 1, cpuUsageAvailable: true,
83
+ memoryUsageAvailable: true, cpuAllocationUse: 25, memoryAllocationUse: 25,
84
+ },
85
+ }
86
+ const trend: OpenCostWorkloadTrendResponse = {
87
+ available: false,
88
+ source: 'kubecost',
89
+ reason: 'history_unsupported',
90
+ currency: 'USD',
91
+ namespace: 'default',
92
+ kind: 'StatefulSet',
93
+ name: 'queue',
94
+ range: '24h',
95
+ }
96
+
97
+ expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_history')
98
+ })
99
+
100
+ it('does not let unsupported history mask current loading or errors', () => {
101
+ const trend: OpenCostWorkloadTrendResponse = {
102
+ available: false,
103
+ source: 'kubecost',
104
+ reason: 'history_unsupported',
105
+ currency: 'USD',
106
+ namespace: 'default',
107
+ kind: 'StatefulSet',
108
+ name: 'queue',
109
+ range: '24h',
110
+ }
111
+
112
+ expect(getWorkloadCostState(undefined, trend, { currentLoading: true })).toBe('loading')
113
+ expect(getWorkloadCostState(undefined, trend, { currentError: true })).toBe('load_error')
114
+ expect(
115
+ getWorkloadCostState(undefined, trend, { currentError: new ApiError('denied', 403) }),
116
+ ).toBe('access_denied')
117
+ })
118
+
72
119
  it('keeps current cost visible while historical owner metrics are still loading', () => {
73
120
  const current: OpenCostWorkloadDetailResponse = {
74
121
  available: true,
@@ -146,6 +193,9 @@ describe('getWorkloadCostState', () => {
146
193
  expect(getWorkloadCostState(undefined, missing, false)).toBe('not_found')
147
194
  expect(getWorkloadCostState(undefined, undefined, { currentError: new ApiError('denied', 403) })).toBe('access_denied')
148
195
  expect(getWorkloadCostState(undefined, undefined, { trendError: new ApiError('missing', 404) })).toBe('not_found')
196
+
197
+ current.reason = 'configuration_mismatch'
198
+ expect(getWorkloadCostState(current, undefined, false)).toBe('configuration_mismatch')
149
199
  })
150
200
 
151
201
  it('shows Prometheus discovery as soon as one query reports it', () => {