@skyhook-io/radar-app 1.10.0 → 1.12.2

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 (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. package/src/utils/wails-clipboard.ts +127 -0
@@ -15,7 +15,7 @@ import type {
15
15
  import {
16
16
  ChevronDown,
17
17
  ChevronRight,
18
- DollarSign,
18
+ Coins,
19
19
  ExternalLink,
20
20
  HelpCircle,
21
21
  Loader2,
@@ -26,6 +26,7 @@ import { PaneLoader, FreshnessControl, PageHeader } from '@skyhook-io/k8s-ui'
26
26
  import { CostTrendChart } from './CostTrendChart'
27
27
  import {
28
28
  COST_HOURS_PER_MONTH,
29
+ DEFAULT_COST_CURRENCY,
29
30
  formatCostPerHour,
30
31
  formatProjectedDailyRate,
31
32
  formatProjectedMonthlyCost,
@@ -125,7 +126,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
125
126
  <CostOverviewState>
126
127
  <div className="flex min-h-64 items-center justify-center">
127
128
  <div className="flex flex-col items-center gap-3 text-theme-text-secondary">
128
- <DollarSign className="w-8 h-8 text-theme-text-tertiary/40" />
129
+ <Coins className="w-8 h-8 text-theme-text-tertiary/40" />
129
130
  <p className="text-sm">{message}</p>
130
131
  <button
131
132
  onClick={onBack}
@@ -152,6 +153,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
152
153
  }
153
154
 
154
155
  const hourlyCost = data.totalHourlyCost ?? 0
156
+ const currency = data.currency ?? DEFAULT_COST_CURRENCY
155
157
  const namespaces = data.namespaces ?? []
156
158
  const totalCpu = namespaces.reduce((sum, ns) => sum + ns.cpuCost, 0)
157
159
  const totalMem = namespaces.reduce((sum, ns) => sum + ns.memoryCost, 0)
@@ -166,6 +168,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
166
168
  const storagePct = allocTotal > 0 ? (totalStorage / allocTotal) * 100 : 0
167
169
 
168
170
  const nodes = nodeData?.available ? (nodeData.nodes ?? []) : []
171
+ const nodeCurrency = nodeData?.currency ?? currency
169
172
  const clusterConsoleLink = clusterCloudConsoleLink(clusterInfo?.context)
170
173
 
171
174
  return (
@@ -175,7 +178,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
175
178
  <div className="flex items-center justify-between">
176
179
  <div className="flex items-center gap-3">
177
180
  <div className="flex items-center gap-2">
178
- <DollarSign className="w-5 h-5 text-indigo-500" />
181
+ <Coins className="w-5 h-5 text-indigo-500" />
179
182
  <h1 className="text-lg font-semibold text-theme-text-primary">Cost Insights</h1>
180
183
  </div>
181
184
  <span className="text-theme-text-quaternary">·</span>
@@ -212,17 +215,17 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
212
215
  <div className="flex flex-col items-end">
213
216
  <div className="flex items-baseline gap-1">
214
217
  <span className="text-2xl font-bold text-theme-text-primary tabular-nums">
215
- {formatProjectedMonthlyCost(hourlyCost)}
218
+ {formatProjectedMonthlyCost(hourlyCost, currency)}
216
219
  </span>
217
220
  <span className="text-xs text-theme-text-tertiary">/mo</span>
218
221
  </div>
219
222
  <div className="mt-0.5 flex items-baseline gap-2 text-theme-text-secondary">
220
223
  <span className="text-sm font-medium tabular-nums">
221
- {formatProjectedDailyRate(hourlyCost)}
224
+ {formatProjectedDailyRate(hourlyCost, currency)}
222
225
  </span>
223
226
  <span className="text-[10px] text-theme-text-quaternary">·</span>
224
227
  <span className="text-sm font-medium tabular-nums">
225
- {formatCostPerHour(hourlyCost)}
228
+ {formatCostPerHour(hourlyCost, currency)}
226
229
  </span>
227
230
  </div>
228
231
  <span className="text-[10px] text-theme-text-quaternary">
@@ -243,16 +246,16 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
243
246
  <div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
244
247
  <span className="flex items-center gap-1.5">
245
248
  <span className="w-2.5 h-2.5 rounded-sm bg-accent" />
246
- CPU {formatProjectedMonthlyRate(totalCpu)}
249
+ CPU {formatProjectedMonthlyRate(totalCpu, currency)}
247
250
  </span>
248
251
  <span className="flex items-center gap-1.5">
249
252
  <span className="w-2.5 h-2.5 rounded-sm bg-amber-500" />
250
- Memory {formatProjectedMonthlyRate(totalMem)}
253
+ Memory {formatProjectedMonthlyRate(totalMem, currency)}
251
254
  </span>
252
255
  {hasStorage && (
253
256
  <span className="flex items-center gap-1.5">
254
257
  <span className="w-2.5 h-2.5 rounded-sm bg-teal-500" />
255
- Storage {formatProjectedMonthlyRate(totalStorage)}
258
+ Storage {formatProjectedMonthlyRate(totalStorage, currency)}
256
259
  </span>
257
260
  )}
258
261
  </div>
@@ -324,6 +327,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
324
327
  ns={ns}
325
328
  maxCost={namespaces[0]?.hourlyCost ?? 0}
326
329
  hasStorage={hasStorage}
330
+ currency={currency}
327
331
  onOpenResource={onOpenResource}
328
332
  />
329
333
  ))}
@@ -331,20 +335,29 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
331
335
  </div>
332
336
 
333
337
  {/* Node cost table */}
334
- {nodes.length > 0 && <NodeCostTable nodes={nodes} onOpenResource={onOpenResource} />}
338
+ {nodes.length > 0 && (
339
+ <NodeCostTable
340
+ nodes={nodes}
341
+ currency={nodeCurrency}
342
+ onOpenResource={onOpenResource}
343
+ />
344
+ )}
335
345
 
336
346
  {/* Footer */}
337
347
  <div className="flex items-center justify-between text-xs text-theme-text-tertiary pb-4">
338
348
  <span>
339
- {data.currency ?? 'USD'} &middot; current rates based on last 1h average &middot;
349
+ {currency} &middot; current rates based on last 1h average &middot;
340
350
  *monthly projections assume {COST_HOURS_PER_MONTH} hrs/mo
351
+ {currency !== DEFAULT_COST_CURRENCY && (
352
+ <> &middot; no conversion</>
353
+ )}
341
354
  </span>
342
355
  <span className="text-indigo-500 font-medium">Powered by OpenCost</span>
343
356
  </div>
344
357
  </div>
345
358
 
346
359
  {/* Help dialog */}
347
- {showHelp && <CostHelpDialog onClose={() => setShowHelp(false)} />}
360
+ {showHelp && <CostHelpDialog currency={currency} onClose={() => setShowHelp(false)} />}
348
361
  </div>
349
362
  )
350
363
  }
@@ -354,7 +367,7 @@ function CostOverviewState({ children }: { children: React.ReactNode }) {
354
367
  <div className="flex-1 overflow-y-auto">
355
368
  <div className="mx-auto flex w-full max-w-[1920px] flex-col gap-4 px-6 py-6">
356
369
  <PageHeader
357
- icon={DollarSign}
370
+ icon={Coins}
358
371
  title="Cost Insights"
359
372
  description="Understand current allocation and find CPU and memory requests worth tuning."
360
373
  />
@@ -369,11 +382,13 @@ function NamespaceCostRow({
369
382
  ns,
370
383
  maxCost,
371
384
  hasStorage,
385
+ currency,
372
386
  onOpenResource,
373
387
  }: {
374
388
  ns: OpenCostNamespaceCost
375
389
  maxCost: number
376
390
  hasStorage: boolean
391
+ currency: string
377
392
  onOpenResource?: (resource: SelectedResource) => void
378
393
  }) {
379
394
  const [expanded, setExpanded] = useState(false)
@@ -410,10 +425,10 @@ function NamespaceCostRow({
410
425
  )}
411
426
  </span>
412
427
  <span className="text-sm font-medium text-theme-text-primary tabular-nums text-right">
413
- {formatProjectedMonthlyCost(ns.hourlyCost)}
428
+ {formatProjectedMonthlyCost(ns.hourlyCost, currency)}
414
429
  </span>
415
430
  <span className="text-sm text-theme-text-secondary tabular-nums text-right">
416
- {formatCostPerHour(ns.hourlyCost)}
431
+ {formatCostPerHour(ns.hourlyCost, currency)}
417
432
  </span>
418
433
  <span className="flex items-center gap-2">
419
434
  <div
@@ -428,10 +443,11 @@ function NamespaceCostRow({
428
443
  </div>
429
444
  </span>
430
445
  <span className="text-[11px] text-theme-text-tertiary tabular-nums text-right">
431
- {formatProjectedMonthlyCost(ns.cpuCost)} / {formatProjectedMonthlyCost(ns.memoryCost)}
446
+ {formatProjectedMonthlyCost(ns.cpuCost, currency)} /{' '}
447
+ {formatProjectedMonthlyCost(ns.memoryCost, currency)}
432
448
  {hasStorage &&
433
449
  (ns.storageCost ?? 0) > 0 &&
434
- ` / ${formatProjectedMonthlyCost(ns.storageCost ?? 0)}`}
450
+ ` / ${formatProjectedMonthlyCost(ns.storageCost ?? 0, currency)}`}
435
451
  </span>
436
452
  </button>
437
453
 
@@ -439,7 +455,13 @@ function NamespaceCostRow({
439
455
  {expanded && isSystemCostNamespace(ns.name) && (
440
456
  <SystemNamespaceCostNote namespace={ns.name} />
441
457
  )}
442
- {expanded && <WorkloadRows namespace={ns.name} onOpenResource={onOpenResource} />}
458
+ {expanded && (
459
+ <WorkloadRows
460
+ namespace={ns.name}
461
+ fallbackCurrency={currency}
462
+ onOpenResource={onOpenResource}
463
+ />
464
+ )}
443
465
  </div>
444
466
  )
445
467
  }
@@ -471,9 +493,11 @@ function SystemNamespacesCostNote() {
471
493
 
472
494
  function WorkloadRows({
473
495
  namespace,
496
+ fallbackCurrency,
474
497
  onOpenResource,
475
498
  }: {
476
499
  namespace: string
500
+ fallbackCurrency: string
477
501
  onOpenResource?: (resource: SelectedResource) => void
478
502
  }) {
479
503
  const { data, isLoading } = useOpenCostWorkloads(namespace)
@@ -488,6 +512,7 @@ function WorkloadRows({
488
512
  }
489
513
 
490
514
  const workloads = data?.workloads ?? []
515
+ const currency = data?.currency ?? fallbackCurrency
491
516
  if (workloads.length === 0) {
492
517
  return (
493
518
  <div className="px-4 py-3 text-xs text-theme-text-tertiary bg-theme-elevated/30 pl-10">
@@ -504,6 +529,7 @@ function WorkloadRows({
504
529
  wl={wl}
505
530
  namespace={namespace}
506
531
  maxCost={workloads[0]?.hourlyCost ?? 0}
532
+ currency={currency}
507
533
  onOpenResource={onOpenResource}
508
534
  />
509
535
  ))}
@@ -515,11 +541,13 @@ function WorkloadCostRow({
515
541
  wl,
516
542
  namespace,
517
543
  maxCost,
544
+ currency,
518
545
  onOpenResource,
519
546
  }: {
520
547
  wl: OpenCostWorkloadCost
521
548
  namespace: string
522
549
  maxCost: number
550
+ currency: string
523
551
  onOpenResource?: (resource: SelectedResource) => void
524
552
  }) {
525
553
  const cpuPct = wl.hourlyCost > 0 ? (wl.cpuCost / wl.hourlyCost) * 100 : 50
@@ -541,10 +569,10 @@ function WorkloadCostRow({
541
569
  )}
542
570
  </span>
543
571
  <span className="text-xs font-medium text-theme-text-secondary tabular-nums text-right">
544
- {formatProjectedMonthlyCost(wl.hourlyCost)}
572
+ {formatProjectedMonthlyCost(wl.hourlyCost, currency)}
545
573
  </span>
546
574
  <span className="text-xs text-theme-text-tertiary tabular-nums text-right">
547
- {formatCostPerHour(wl.hourlyCost)}
575
+ {formatCostPerHour(wl.hourlyCost, currency)}
548
576
  </span>
549
577
  <span className="flex items-center gap-2">
550
578
  <div
@@ -556,7 +584,8 @@ function WorkloadCostRow({
556
584
  </div>
557
585
  </span>
558
586
  <span className="text-[10px] text-theme-text-tertiary tabular-nums text-right">
559
- {formatProjectedMonthlyCost(wl.cpuCost)} / {formatProjectedMonthlyCost(wl.memoryCost)}
587
+ {formatProjectedMonthlyCost(wl.cpuCost, currency)} /{' '}
588
+ {formatProjectedMonthlyCost(wl.memoryCost, currency)}
560
589
  </span>
561
590
  </>
562
591
  )
@@ -582,9 +611,11 @@ function WorkloadCostRow({
582
611
 
583
612
  function NodeCostTable({
584
613
  nodes,
614
+ currency,
585
615
  onOpenResource,
586
616
  }: {
587
617
  nodes: OpenCostNodeCost[]
618
+ currency: string
588
619
  onOpenResource?: (resource: SelectedResource) => void
589
620
  }) {
590
621
  return (
@@ -627,7 +658,12 @@ function NodeCostTable({
627
658
  {/* Node rows */}
628
659
  <div className="divide-y divide-theme-border/50">
629
660
  {nodes.map((node) => (
630
- <NodeCostRow key={node.name} node={node} onOpenResource={onOpenResource} />
661
+ <NodeCostRow
662
+ key={node.name}
663
+ node={node}
664
+ currency={currency}
665
+ onOpenResource={onOpenResource}
666
+ />
631
667
  ))}
632
668
  </div>
633
669
  </div>
@@ -636,9 +672,11 @@ function NodeCostTable({
636
672
 
637
673
  function NodeCostRow({
638
674
  node,
675
+ currency,
639
676
  onOpenResource,
640
677
  }: {
641
678
  node: OpenCostNodeCost
679
+ currency: string
642
680
  onOpenResource?: (resource: SelectedResource) => void
643
681
  }) {
644
682
  const cloudLink = nodeCloudConsoleLink(node.providerID)
@@ -680,13 +718,14 @@ function NodeCostRow({
680
718
  {node.region && <span className="text-theme-text-quaternary ml-1.5">({node.region})</span>}
681
719
  </span>
682
720
  <span className="text-sm font-medium text-theme-text-primary tabular-nums text-right">
683
- {formatProjectedMonthlyCost(node.hourlyCost)}
721
+ {formatProjectedMonthlyCost(node.hourlyCost, currency)}
684
722
  </span>
685
723
  <span className="text-sm text-theme-text-secondary tabular-nums text-right">
686
- {formatCostPerHour(node.hourlyCost)}
724
+ {formatCostPerHour(node.hourlyCost, currency)}
687
725
  </span>
688
726
  <span className="text-[11px] text-theme-text-tertiary tabular-nums text-right">
689
- {formatProjectedMonthlyCost(node.cpuCost)} / {formatProjectedMonthlyCost(node.memoryCost)}
727
+ {formatProjectedMonthlyCost(node.cpuCost, currency)} /{' '}
728
+ {formatProjectedMonthlyCost(node.memoryCost, currency)}
690
729
  </span>
691
730
  </div>
692
731
  )
@@ -729,7 +768,7 @@ function apiGroupForCostWorkload(kind: string): string | undefined {
729
768
 
730
769
  // --- Help dialog ---
731
770
 
732
- function CostHelpDialog({ onClose }: { onClose: () => void }) {
771
+ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () => void }) {
733
772
  useEffect(() => {
734
773
  const handleKeyDown = (e: KeyboardEvent) => {
735
774
  if (e.key === 'Escape') onClose()
@@ -767,8 +806,20 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
767
806
  <p>
768
807
  Cost data comes from <strong>OpenCost</strong>, an open-source tool that combines your
769
808
  cloud provider's pricing (how much each node costs per hour) with Kubernetes resource
770
- allocation data. This gives you a dollar value for each workload running on your
771
- cluster.
809
+ allocation data. This gives you a cost value for each workload running on your cluster.
810
+ </p>
811
+ </section>
812
+
813
+ <section>
814
+ <h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
815
+ Which currency is shown?
816
+ </h3>
817
+ <p>
818
+ 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{' '}
822
+ <code>--opencost-currency</code> (Helm: <code>cost.currency</code>).
772
823
  </p>
773
824
  </section>
774
825
 
@@ -4,6 +4,7 @@ import { Tooltip } from '../ui/Tooltip'
4
4
  import { formatCostPerHour, formatProjectedMonthlyRate } from './format'
5
5
 
6
6
  interface CurrentAllocationUseProps {
7
+ currency: string
7
8
  dataAvailable: boolean
8
9
  cpuCost: number
9
10
  memoryCost: number
@@ -32,6 +33,7 @@ export function formatAllocatedUse(
32
33
  }
33
34
 
34
35
  export function CurrentAllocationUse({
36
+ currency,
35
37
  dataAvailable,
36
38
  cpuCost,
37
39
  memoryCost,
@@ -63,11 +65,11 @@ export function CurrentAllocationUse({
63
65
  </div>
64
66
  <div className="text-right">
65
67
  <div className="text-sm font-medium text-theme-text-primary tabular-nums">
66
- {dataAvailable ? formatProjectedMonthlyRate(hourlyCost) : '—'}
68
+ {dataAvailable ? formatProjectedMonthlyRate(hourlyCost, currency) : '—'}
67
69
  </div>
68
70
  {dataAvailable && (
69
71
  <div className="text-[10px] text-theme-text-tertiary tabular-nums">
70
- {formatCostPerHour(hourlyCost)} current rate
72
+ {formatCostPerHour(hourlyCost, currency)} current rate
71
73
  </div>
72
74
  )}
73
75
  </div>
@@ -86,13 +88,13 @@ export function CurrentAllocationUse({
86
88
  <AllocationUseItem
87
89
  colorClass="bg-accent"
88
90
  label="CPU"
89
- allocation={dataAvailable ? formatProjectedMonthlyRate(cpuCost) : '—'}
91
+ allocation={dataAvailable ? formatProjectedMonthlyRate(cpuCost, currency) : '—'}
90
92
  use={formatAllocatedUse(cpuCost, cpuAllocationUse, cpuUsageAvailable, dataAvailable)}
91
93
  />
92
94
  <AllocationUseItem
93
95
  colorClass="bg-amber-500"
94
96
  label="Memory"
95
- allocation={dataAvailable ? formatProjectedMonthlyRate(memoryCost) : '—'}
97
+ allocation={dataAvailable ? formatProjectedMonthlyRate(memoryCost, currency) : '—'}
96
98
  use={formatAllocatedUse(memoryCost, memoryAllocationUse, memoryUsageAvailable, dataAvailable)}
97
99
  />
98
100
  </div>
@@ -6,6 +6,7 @@ describe('getWorkloadCostState', () => {
6
6
  it('treats scaled-to-zero as a valid zero state', () => {
7
7
  const current: OpenCostWorkloadDetailResponse = {
8
8
  available: true,
9
+ currency: 'USD',
9
10
  namespace: 'default',
10
11
  kind: 'Deployment',
11
12
  name: 'checkout',
@@ -24,6 +25,7 @@ describe('getWorkloadCostState', () => {
24
25
  }
25
26
  const trend: OpenCostWorkloadTrendResponse = {
26
27
  available: false,
28
+ currency: 'USD',
27
29
  reason: 'no_metrics',
28
30
  namespace: 'default',
29
31
  kind: 'Deployment',
@@ -37,6 +39,7 @@ describe('getWorkloadCostState', () => {
37
39
  it('keeps current cost visible when only historical owner metrics are missing', () => {
38
40
  const current: OpenCostWorkloadDetailResponse = {
39
41
  available: true,
42
+ currency: 'USD',
40
43
  namespace: 'default',
41
44
  kind: 'Deployment',
42
45
  name: 'checkout',
@@ -55,6 +58,7 @@ describe('getWorkloadCostState', () => {
55
58
  }
56
59
  const trend: OpenCostWorkloadTrendResponse = {
57
60
  available: false,
61
+ currency: 'USD',
58
62
  reason: 'no_metrics',
59
63
  namespace: 'default',
60
64
  kind: 'Deployment',
@@ -68,6 +72,7 @@ describe('getWorkloadCostState', () => {
68
72
  it('keeps current cost visible while historical owner metrics are still loading', () => {
69
73
  const current: OpenCostWorkloadDetailResponse = {
70
74
  available: true,
75
+ currency: 'USD',
71
76
  namespace: 'default',
72
77
  kind: 'Deployment',
73
78
  name: 'checkout',
@@ -91,6 +96,7 @@ describe('getWorkloadCostState', () => {
91
96
  it('uses historical data when current metrics are absent but history exists', () => {
92
97
  const current: OpenCostWorkloadDetailResponse = {
93
98
  available: false,
99
+ currency: 'USD',
94
100
  reason: 'no_metrics',
95
101
  namespace: 'default',
96
102
  kind: 'Deployment',
@@ -98,6 +104,7 @@ describe('getWorkloadCostState', () => {
98
104
  }
99
105
  const trend: OpenCostWorkloadTrendResponse = {
100
106
  available: true,
107
+ currency: 'USD',
101
108
  namespace: 'default',
102
109
  kind: 'Deployment',
103
110
  name: 'checkout',
@@ -118,6 +125,7 @@ describe('getWorkloadCostState', () => {
118
125
  it('surfaces workload access and existence failures', () => {
119
126
  const current: OpenCostWorkloadDetailResponse = {
120
127
  available: false,
128
+ currency: 'USD',
121
129
  reason: 'access_denied',
122
130
  namespace: 'prod',
123
131
  kind: 'Deployment',
@@ -126,6 +134,7 @@ describe('getWorkloadCostState', () => {
126
134
 
127
135
  const missing: OpenCostWorkloadTrendResponse = {
128
136
  available: false,
137
+ currency: 'USD',
129
138
  reason: 'not_found',
130
139
  namespace: 'prod',
131
140
  kind: 'Deployment',
@@ -142,6 +151,7 @@ describe('getWorkloadCostState', () => {
142
151
  it('shows Prometheus discovery as soon as one query reports it', () => {
143
152
  const current: OpenCostWorkloadDetailResponse = {
144
153
  available: false,
154
+ currency: 'USD',
145
155
  reason: 'no_prometheus',
146
156
  namespace: 'default',
147
157
  kind: 'Deployment',
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
2
+ import { AlertCircle, Coins, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
3
3
  import {
4
4
  useOpenCostWorkload,
5
5
  useOpenCostWorkloadTrend,
@@ -12,6 +12,7 @@ import {
12
12
  import { Tooltip } from '../ui/Tooltip'
13
13
  import { CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart'
14
14
  import {
15
+ DEFAULT_COST_CURRENCY,
15
16
  formatCostPerHour,
16
17
  formatHistoricalSpend,
17
18
  formatProjectedDailyRate,
@@ -110,10 +111,13 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
110
111
  const windowTotal = trend?.available ? (trend.windowTotalCost ?? 0) : 0
111
112
  const cpuCost = current?.cpuCost ?? 0
112
113
  const memoryCost = current?.memoryCost ?? 0
114
+ const currentCurrency = currentQuery.data?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY
115
+ const trendCurrency = trend?.currency ?? currentQuery.data?.currency ?? DEFAULT_COST_CURRENCY
113
116
  const windowSpendValue = formatHistoricalSpend(
114
117
  points.length,
115
118
  windowTotal,
116
119
  trendLoading || state === 'partial_missing_history',
120
+ trendCurrency,
117
121
  )
118
122
 
119
123
  return (
@@ -127,10 +131,11 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
127
131
  <div className="text-sm font-semibold text-theme-text-primary">
128
132
  Historical compute cost
129
133
  </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." />
134
+ <MetricInfoTooltip content="Values are based on OpenCost CPU and memory allocation over time, not raw utilization. OpenCost allocation uses the greater of requested or observed resources." />
131
135
  </div>
132
136
  <div className="text-xs text-theme-text-tertiary">
133
- OpenCost CPU and memory allocation rate ($/hr) attributed by workload ownership
137
+ OpenCost CPU and memory allocation rate ({trendCurrency}/hr) attributed by workload
138
+ ownership
134
139
  </div>
135
140
  </div>
136
141
  </div>
@@ -148,10 +153,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
148
153
  />
149
154
  <MetricBlock
150
155
  label="Projected monthly"
151
- value={hasCurrent ? formatProjectedMonthlyCost(hourly) : '—'}
156
+ value={hasCurrent ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
152
157
  subvalue={
153
158
  hasCurrent
154
- ? `${formatCostPerHour(hourly)} current rate`
159
+ ? `${formatCostPerHour(hourly, currentCurrency)} current rate`
155
160
  : 'Current allocation unavailable'
156
161
  }
157
162
  />
@@ -163,7 +168,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
163
168
  Loading historical cost…
164
169
  </div>
165
170
  ) : hasTrend ? (
166
- <StackedAreaChart series={[{ namespace: 'Allocation rate', dataPoints: points }]} />
171
+ <StackedAreaChart
172
+ series={[{ namespace: 'Allocation rate', dataPoints: points }]}
173
+ currency={trendCurrency}
174
+ />
167
175
  ) : (
168
176
  <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
177
  No historical workload owner cost points for this range.
@@ -195,16 +203,17 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
195
203
  <MetricTile label="Replicas" value={hasCurrent ? String(current?.replicas ?? 0) : '—'} />
196
204
  <MetricTile
197
205
  label="Projected daily"
198
- value={hasCurrent ? formatProjectedDailyRate(hourly) : '—'}
206
+ value={hasCurrent ? formatProjectedDailyRate(hourly, currentCurrency) : '—'}
199
207
  subvalue={
200
208
  hasCurrent
201
- ? `${formatCostPerHour(hourly)} current hourly rate`
209
+ ? `${formatCostPerHour(hourly, currentCurrency)} current hourly rate`
202
210
  : 'Current allocation unavailable'
203
211
  }
204
212
  />
205
213
  </div>
206
214
 
207
215
  <CurrentAllocationUse
216
+ currency={currentCurrency}
208
217
  dataAvailable={hasCurrent}
209
218
  cpuCost={cpuCost}
210
219
  memoryCost={memoryCost}
@@ -216,9 +225,12 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
216
225
  />
217
226
 
218
227
  <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.
228
+ Powered by OpenCost via Prometheus.{' '}
229
+ {currentCurrency !== DEFAULT_COST_CURRENCY && (
230
+ <>Labeled {currentCurrency}; no conversion. </>
231
+ )}
232
+ Historical spend uses the selected range; projected monthly values multiply the current
233
+ hourly allocation. Storage/PVC attribution remains at namespace and cluster level.
222
234
  </div>
223
235
  </div>
224
236
  )
@@ -313,7 +325,7 @@ function WorkloadCostUnavailable({ state }: { state: CostUnavailableReason | 'lo
313
325
  return (
314
326
  <div className="flex h-full min-h-[320px] items-center justify-center">
315
327
  <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" />
328
+ <Coins className="h-8 w-8 text-theme-text-tertiary/50" />
317
329
  <div className="text-sm">{message}</div>
318
330
  </div>
319
331
  </div>
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
+ formatCostAxis,
3
4
  formatCostPerHour,
4
5
  formatHistoricalSpend,
5
6
  formatProjectedDailyRate,
@@ -9,19 +10,37 @@ import {
9
10
 
10
11
  describe('cost formatters', () => {
11
12
  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')
13
+ expect(formatProjectedDailyRate(0.1, 'USD')).toBe('~$2.40/day')
14
+ expect(formatProjectedMonthlyCost(1, 'USD')).toBe('~$730.00')
15
+ expect(formatProjectedMonthlyRate(0.1, 'USD')).toBe('~$73.00/mo')
15
16
  })
16
17
 
17
18
  it('keeps hourly rates explicit', () => {
18
- expect(formatCostPerHour(0.1)).toBe('$0.100/hr')
19
+ expect(formatCostPerHour(0.1, 'USD')).toBe('$0.100/hr')
19
20
  })
20
21
 
21
22
  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('—')
23
+ expect(formatHistoricalSpend(1, 0, false, 'USD')).toBe('—')
24
+ expect(formatHistoricalSpend(2, 0, false, 'USD')).toBe('$0.00')
25
+ expect(formatHistoricalSpend(2, 1.25, false, 'USD')).toBe('~$1.25')
26
+ expect(formatHistoricalSpend(2, 1.25, true, 'USD')).toBe('—')
27
+ })
28
+
29
+ it('uses the configured currency and its major-unit precision', () => {
30
+ expect(formatProjectedDailyRate(0.1, 'EUR')).toBe('~€2.40/day')
31
+ expect(formatCostPerHour(0.1, 'GBP')).toBe('£0.100/hr')
32
+ expect(formatProjectedMonthlyCost(1, 'JPY')).toBe('~¥730')
33
+ expect(formatCostPerHour(0.1, 'JPY')).toBe('¥0.100/hr')
34
+ expect(formatHistoricalSpend(2, 0, false, 'JPY')).toBe('¥0')
35
+ expect(formatCostAxis(0.000001, 'GBP')).toBe('<£0.00001')
36
+ })
37
+
38
+ it('normalizes codes and labels malformed currencies without impersonating USD', () => {
39
+ expect(formatProjectedMonthlyCost(1, ' eur ')).toBe('~€730.00')
40
+ expect(formatProjectedMonthlyCost(1, 'not-a-code')).toBe('~NOT-A-CODE 730.00')
41
+ })
42
+
43
+ it('falls back to USD when an older backend omits currency', () => {
44
+ expect(formatProjectedMonthlyCost(1, undefined)).toBe('~$730.00')
26
45
  })
27
46
  })