@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.
- package/package.json +10 -10
- package/src/App.tsx +5 -8
- package/src/api/client.ts +174 -12
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- package/src/components/ConnectionErrorView.test.tsx +53 -0
- package/src/components/ConnectionErrorView.tsx +15 -12
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/audit/UpgradeReadinessView.tsx +3 -3
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/home/ClusterHealthCard.tsx +3 -0
- package/src/components/home/CostCard.tsx +12 -7
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +27 -5
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +160 -36
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- package/src/utils/topology-selection.ts +39 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
import {
|
|
16
16
|
ChevronDown,
|
|
17
17
|
ChevronRight,
|
|
18
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 &&
|
|
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
|
-
{
|
|
349
|
+
{currency} · current rates based on last 1h average ·
|
|
340
350
|
*monthly projections assume {COST_HOURS_PER_MONTH} hrs/mo
|
|
351
|
+
{currency !== DEFAULT_COST_CURRENCY && (
|
|
352
|
+
<> · 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={
|
|
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)} /
|
|
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 &&
|
|
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)} /
|
|
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
|
|
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)} /
|
|
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
|
|
771
|
-
|
|
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,
|
|
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="
|
|
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 (
|
|
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
|
|
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.
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
<
|
|
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
|
})
|