@skyhook-io/radar-app 1.8.7 → 1.8.8
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 +4 -4
- package/src/App.tsx +58 -50
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1222 -234
- package/src/api/timelineSource.ts +4 -2
- package/src/components/applications/ApplicationsView.tsx +613 -219
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/diagnose/AISettings.tsx +7 -12
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/ValuesDiffPreview.tsx +2 -3
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +319 -123
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +66 -116
- package/src/components/settings/SettingsDialog.tsx +1268 -318
- package/src/components/timeline/TimelineList.tsx +35 -8
- package/src/components/timeline/TimelineView.tsx +156 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
- package/src/components/workload/WorkloadView.tsx +711 -328
- package/src/index.css +5 -1
- package/src/main.tsx +1 -1
|
@@ -1,60 +1,163 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { useLocation } from 'react-router-dom'
|
|
3
|
+
import {
|
|
4
|
+
COST_DISCOVERY_GRACE_MS,
|
|
5
|
+
useClusterInfo,
|
|
6
|
+
useOpenCostSummary,
|
|
7
|
+
useOpenCostWorkloads,
|
|
8
|
+
useOpenCostNodes,
|
|
9
|
+
} from '../../api/client'
|
|
10
|
+
import type {
|
|
11
|
+
OpenCostNamespaceCost,
|
|
12
|
+
OpenCostWorkloadCost,
|
|
13
|
+
OpenCostNodeCost,
|
|
14
|
+
} from '../../api/client'
|
|
15
|
+
import {
|
|
16
|
+
ChevronDown,
|
|
17
|
+
ChevronRight,
|
|
18
|
+
DollarSign,
|
|
19
|
+
ExternalLink,
|
|
20
|
+
HelpCircle,
|
|
21
|
+
Loader2,
|
|
22
|
+
Server,
|
|
23
|
+
X,
|
|
24
|
+
} from 'lucide-react'
|
|
25
|
+
import { PaneLoader, FreshnessControl, PageHeader } from '@skyhook-io/k8s-ui'
|
|
6
26
|
import { CostTrendChart } from './CostTrendChart'
|
|
27
|
+
import {
|
|
28
|
+
COST_HOURS_PER_MONTH,
|
|
29
|
+
formatCostPerHour,
|
|
30
|
+
formatProjectedDailyRate,
|
|
31
|
+
formatProjectedMonthlyCost,
|
|
32
|
+
formatProjectedMonthlyRate,
|
|
33
|
+
} from './format'
|
|
7
34
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
35
|
import { useConnection } from '../../context/ConnectionContext'
|
|
36
|
+
import type { SelectedResource } from '../../types'
|
|
37
|
+
import { kindToPlural, openExternal } from '../../utils/navigation'
|
|
38
|
+
import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console'
|
|
39
|
+
import { RightsizingScanView } from '../rightsizing/RightsizingScanView'
|
|
40
|
+
import { CostViewTabs } from './CostViewTabs'
|
|
9
41
|
|
|
10
42
|
interface CostViewProps {
|
|
11
43
|
onBack: () => void
|
|
44
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
45
|
+
namespaces: string[]
|
|
12
46
|
}
|
|
13
47
|
|
|
14
|
-
|
|
15
|
-
|
|
48
|
+
const SYSTEM_COST_NAMESPACES = new Set(['kube-system', 'kube-public', 'kube-node-lease'])
|
|
49
|
+
|
|
50
|
+
export function CostView(props: CostViewProps) {
|
|
51
|
+
const { pathname } = useLocation()
|
|
52
|
+
if (pathname.startsWith('/cost/rightsizing')) {
|
|
53
|
+
return <RightsizingScanView namespaces={props.namespaces} />
|
|
54
|
+
}
|
|
55
|
+
return <CostOverview {...props} />
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
59
|
+
const { data, isLoading, isFetching, dataUpdatedAt, refetch } = useOpenCostSummary()
|
|
16
60
|
const { data: nodeData } = useOpenCostNodes()
|
|
61
|
+
const { data: clusterInfo } = useClusterInfo()
|
|
17
62
|
const { connection } = useConnection()
|
|
18
63
|
const [showHelp, setShowHelp] = useState(false)
|
|
64
|
+
const [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (data?.available === false && data.reason === 'no_prometheus') {
|
|
68
|
+
setNoPrometheusSince((prev) => prev ?? Date.now())
|
|
69
|
+
} else {
|
|
70
|
+
setNoPrometheusSince(null)
|
|
71
|
+
}
|
|
72
|
+
}, [data?.available, data?.reason])
|
|
19
73
|
|
|
20
74
|
if (isLoading) {
|
|
21
|
-
return
|
|
75
|
+
return (
|
|
76
|
+
<CostOverviewState>
|
|
77
|
+
<PaneLoader label="Loading cost data…" className="min-h-64" />
|
|
78
|
+
</CostOverviewState>
|
|
79
|
+
)
|
|
22
80
|
}
|
|
23
81
|
|
|
24
82
|
if (!data || !data.available) {
|
|
25
83
|
const reason = data?.reason
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
84
|
+
const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
|
|
85
|
+
if (reason === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
|
|
86
|
+
return (
|
|
87
|
+
<CostOverviewState>
|
|
88
|
+
<div className="flex min-h-64 items-center justify-center">
|
|
89
|
+
<div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
|
|
90
|
+
<Loader2 className="w-8 h-8 animate-spin text-theme-text-tertiary/60" />
|
|
91
|
+
<div>
|
|
92
|
+
<p className="text-sm font-medium text-theme-text-primary">
|
|
93
|
+
Looking for Prometheus cost data…
|
|
94
|
+
</p>
|
|
95
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
96
|
+
First discovery can take a few seconds while Radar checks cluster services and
|
|
97
|
+
opens a local port-forward.
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
<button
|
|
101
|
+
onClick={() => {
|
|
102
|
+
setNoPrometheusSince(Date.now())
|
|
103
|
+
refetch()
|
|
104
|
+
}}
|
|
105
|
+
disabled={isFetching}
|
|
106
|
+
className="text-xs text-accent-text hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled transition-colors"
|
|
107
|
+
>
|
|
108
|
+
{isFetching ? 'Checking…' : 'Check again'}
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</CostOverviewState>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
const message =
|
|
116
|
+
reason === 'no_prometheus'
|
|
117
|
+
? 'Prometheus not found — OpenCost requires Prometheus or VictoriaMetrics'
|
|
118
|
+
: reason === 'no_metrics'
|
|
119
|
+
? 'OpenCost metrics not found — Prometheus is available but no cost metrics were detected'
|
|
120
|
+
: reason === 'query_error'
|
|
121
|
+
? 'Cost data temporarily unavailable — Prometheus was found but queries failed'
|
|
122
|
+
: 'OpenCost not detected — install OpenCost for cost visibility'
|
|
33
123
|
|
|
34
124
|
return (
|
|
35
|
-
<
|
|
36
|
-
<div className="flex
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
125
|
+
<CostOverviewState>
|
|
126
|
+
<div className="flex min-h-64 items-center justify-center">
|
|
127
|
+
<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
|
+
<p className="text-sm">{message}</p>
|
|
130
|
+
<button
|
|
131
|
+
onClick={onBack}
|
|
132
|
+
className="text-xs text-skyhook-400 hover:text-skyhook-300 transition-colors"
|
|
133
|
+
>
|
|
134
|
+
Back to Dashboard
|
|
135
|
+
</button>
|
|
136
|
+
{reason === 'no_prometheus' && (
|
|
137
|
+
<button
|
|
138
|
+
onClick={() => {
|
|
139
|
+
setNoPrometheusSince(Date.now())
|
|
140
|
+
refetch()
|
|
141
|
+
}}
|
|
142
|
+
disabled={isFetching}
|
|
143
|
+
className="text-xs text-accent-text hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled transition-colors"
|
|
144
|
+
>
|
|
145
|
+
{isFetching ? 'Checking…' : 'Check again'}
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
45
149
|
</div>
|
|
46
|
-
</
|
|
150
|
+
</CostOverviewState>
|
|
47
151
|
)
|
|
48
152
|
}
|
|
49
153
|
|
|
50
154
|
const hourlyCost = data.totalHourlyCost ?? 0
|
|
51
|
-
const monthlyCost = hourlyCost * 730
|
|
52
155
|
const namespaces = data.namespaces ?? []
|
|
53
156
|
const totalCpu = namespaces.reduce((sum, ns) => sum + ns.cpuCost, 0)
|
|
54
157
|
const totalMem = namespaces.reduce((sum, ns) => sum + ns.memoryCost, 0)
|
|
55
158
|
const totalStorage = data.totalStorageCost ?? 0
|
|
56
159
|
const hasStorage = totalStorage > 0
|
|
57
|
-
const
|
|
160
|
+
const hasSystemNamespaces = namespaces.some((ns) => isSystemCostNamespace(ns.name))
|
|
58
161
|
|
|
59
162
|
// Compute split percentages (CPU + Memory + optional Storage)
|
|
60
163
|
const allocTotal = totalCpu + totalMem + totalStorage
|
|
@@ -62,22 +165,15 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
62
165
|
const memPct = allocTotal > 0 ? (totalMem / allocTotal) * 100 : 50
|
|
63
166
|
const storagePct = allocTotal > 0 ? (totalStorage / allocTotal) * 100 : 0
|
|
64
167
|
|
|
65
|
-
const nodes = nodeData?.available ? nodeData.nodes ?? [] : []
|
|
168
|
+
const nodes = nodeData?.available ? (nodeData.nodes ?? []) : []
|
|
169
|
+
const clusterConsoleLink = clusterCloudConsoleLink(clusterInfo?.context)
|
|
66
170
|
|
|
67
171
|
return (
|
|
68
172
|
<div className="flex-1 overflow-y-auto">
|
|
69
|
-
<div className="max-w-[
|
|
173
|
+
<div className="mx-auto w-full max-w-[1920px] px-6 py-6 space-y-6">
|
|
70
174
|
{/* Header */}
|
|
71
175
|
<div className="flex items-center justify-between">
|
|
72
176
|
<div className="flex items-center gap-3">
|
|
73
|
-
<button
|
|
74
|
-
onClick={onBack}
|
|
75
|
-
className="flex items-center gap-1.5 text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
76
|
-
>
|
|
77
|
-
<ArrowLeft className="w-4 h-4" />
|
|
78
|
-
Dashboard
|
|
79
|
-
</button>
|
|
80
|
-
<div className="w-px h-5 bg-theme-border" />
|
|
81
177
|
<div className="flex items-center gap-2">
|
|
82
178
|
<DollarSign className="w-5 h-5 text-indigo-500" />
|
|
83
179
|
<h1 className="text-lg font-semibold text-theme-text-primary">Cost Insights</h1>
|
|
@@ -90,9 +186,22 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
90
186
|
<HelpCircle className="w-3.5 h-3.5" />
|
|
91
187
|
How this works
|
|
92
188
|
</button>
|
|
189
|
+
{clusterConsoleLink && (
|
|
190
|
+
<Tooltip content={clusterConsoleLink.label}>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={() => openExternal(clusterConsoleLink.url)}
|
|
194
|
+
className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-accent-text transition-colors duration-150"
|
|
195
|
+
aria-label={clusterConsoleLink.label}
|
|
196
|
+
>
|
|
197
|
+
<ExternalLink className="w-3.5 h-3.5" />
|
|
198
|
+
Cloud console
|
|
199
|
+
</button>
|
|
200
|
+
</Tooltip>
|
|
201
|
+
)}
|
|
93
202
|
</div>
|
|
94
203
|
<div className="flex items-center gap-4">
|
|
95
|
-
{/* Tracks the headline
|
|
204
|
+
{/* Tracks the headline monthly summary (the primary query); its load
|
|
96
205
|
time is the representative freshness signal for the view. */}
|
|
97
206
|
<FreshnessControl
|
|
98
207
|
mode="auto"
|
|
@@ -100,64 +209,61 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
100
209
|
onRefresh={() => refetch()}
|
|
101
210
|
connectionState={connection.state}
|
|
102
211
|
/>
|
|
103
|
-
|
|
104
|
-
<div className="flex
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
{(data.clusterEfficiency ?? 0).toFixed(0)}% efficient
|
|
108
|
-
</span>
|
|
109
|
-
</div>
|
|
110
|
-
<span className="text-[10px] text-theme-text-tertiary">
|
|
111
|
-
~{formatCost((data.totalIdleCost ?? 0) * 730)}/mo unused capacity
|
|
212
|
+
<div className="flex flex-col items-end">
|
|
213
|
+
<div className="flex items-baseline gap-1">
|
|
214
|
+
<span className="text-2xl font-bold text-theme-text-primary tabular-nums">
|
|
215
|
+
{formatProjectedMonthlyCost(hourlyCost)}
|
|
112
216
|
</span>
|
|
217
|
+
<span className="text-xs text-theme-text-tertiary">/mo</span>
|
|
113
218
|
</div>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
</div>
|
|
123
|
-
<div className="flex items-baseline gap-1 text-theme-text-secondary">
|
|
124
|
-
<span className="text-sm font-medium tabular-nums">~{formatCost(monthlyCost)}</span>
|
|
125
|
-
<span className="text-[10px] text-theme-text-tertiary">/mo</span>
|
|
126
|
-
</div>
|
|
219
|
+
<div className="mt-0.5 flex items-baseline gap-2 text-theme-text-secondary">
|
|
220
|
+
<span className="text-sm font-medium tabular-nums">
|
|
221
|
+
{formatProjectedDailyRate(hourlyCost)}
|
|
222
|
+
</span>
|
|
223
|
+
<span className="text-[10px] text-theme-text-quaternary">·</span>
|
|
224
|
+
<span className="text-sm font-medium tabular-nums">
|
|
225
|
+
{formatCostPerHour(hourlyCost)}
|
|
226
|
+
</span>
|
|
127
227
|
</div>
|
|
128
|
-
<span className="text-[10px] text-theme-text-quaternary">
|
|
228
|
+
<span className="text-[10px] text-theme-text-quaternary">
|
|
229
|
+
projected from last 1h average
|
|
230
|
+
</span>
|
|
129
231
|
</div>
|
|
130
232
|
</div>
|
|
131
233
|
</div>
|
|
132
234
|
|
|
235
|
+
<CostViewTabs />
|
|
236
|
+
|
|
133
237
|
{/* CPU vs Memory (vs Storage) split bar */}
|
|
134
238
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
135
239
|
<div className="flex items-center justify-between mb-2">
|
|
136
|
-
<span className="text-xs font-medium text-theme-text-secondary">
|
|
240
|
+
<span className="text-xs font-medium text-theme-text-secondary">
|
|
241
|
+
Cluster Resource Cost
|
|
242
|
+
</span>
|
|
137
243
|
<div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
|
|
138
244
|
<span className="flex items-center gap-1.5">
|
|
139
|
-
<span className="w-2.5 h-2.5 rounded-sm bg-
|
|
140
|
-
CPU {
|
|
245
|
+
<span className="w-2.5 h-2.5 rounded-sm bg-accent" />
|
|
246
|
+
CPU {formatProjectedMonthlyRate(totalCpu)}
|
|
141
247
|
</span>
|
|
142
248
|
<span className="flex items-center gap-1.5">
|
|
143
|
-
<span className="w-2.5 h-2.5 rounded-sm bg-
|
|
144
|
-
Memory {
|
|
249
|
+
<span className="w-2.5 h-2.5 rounded-sm bg-amber-500" />
|
|
250
|
+
Memory {formatProjectedMonthlyRate(totalMem)}
|
|
145
251
|
</span>
|
|
146
252
|
{hasStorage && (
|
|
147
253
|
<span className="flex items-center gap-1.5">
|
|
148
254
|
<span className="w-2.5 h-2.5 rounded-sm bg-teal-500" />
|
|
149
|
-
Storage {
|
|
255
|
+
Storage {formatProjectedMonthlyRate(totalStorage)}
|
|
150
256
|
</span>
|
|
151
257
|
)}
|
|
152
258
|
</div>
|
|
153
259
|
</div>
|
|
154
260
|
<div className="h-3 rounded-full overflow-hidden bg-theme-hover flex">
|
|
155
261
|
<div
|
|
156
|
-
className="h-full bg-
|
|
262
|
+
className="h-full bg-accent transition-all duration-300"
|
|
157
263
|
style={{ width: `${cpuPct}%` }}
|
|
158
264
|
/>
|
|
159
265
|
<div
|
|
160
|
-
className="h-full bg-
|
|
266
|
+
className="h-full bg-amber-500 transition-all duration-300"
|
|
161
267
|
style={{ width: `${memPct}%` }}
|
|
162
268
|
/>
|
|
163
269
|
{hasStorage && (
|
|
@@ -177,42 +283,61 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
177
283
|
<div className="px-4 py-3 border-b border-theme-border">
|
|
178
284
|
<div className="flex items-center justify-between">
|
|
179
285
|
<div>
|
|
180
|
-
<span className="text-sm font-semibold text-theme-text-primary">
|
|
181
|
-
|
|
286
|
+
<span className="text-sm font-semibold text-theme-text-primary">
|
|
287
|
+
Namespace Breakdown
|
|
288
|
+
</span>
|
|
289
|
+
<span className="text-[10px] text-theme-text-quaternary ml-2">
|
|
290
|
+
projected monthly from current rate
|
|
291
|
+
</span>
|
|
182
292
|
</div>
|
|
183
|
-
<span className="text-xs text-theme-text-tertiary">
|
|
293
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
294
|
+
{namespaces.length} namespaces
|
|
295
|
+
</span>
|
|
184
296
|
</div>
|
|
185
297
|
</div>
|
|
298
|
+
{hasSystemNamespaces && <SystemNamespacesCostNote />}
|
|
186
299
|
|
|
187
300
|
{/* Table header */}
|
|
188
|
-
<div className="grid grid-cols-[minmax(180px,1fr)
|
|
301
|
+
<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">
|
|
189
302
|
<span>Namespace</span>
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
<span className="cursor-help">Efficiency</span>
|
|
303
|
+
<Tooltip
|
|
304
|
+
content="Projected from current hourly rate — not historical spend"
|
|
305
|
+
wrapperClassName="!block text-right"
|
|
306
|
+
>
|
|
307
|
+
<span className="cursor-help">Projected/mo*</span>
|
|
196
308
|
</Tooltip>
|
|
309
|
+
<span className="text-right">Hourly</span>
|
|
197
310
|
<span>CPU / Memory</span>
|
|
198
|
-
<
|
|
311
|
+
<Tooltip
|
|
312
|
+
content="Projected monthly CPU and memory allocation from the current hourly rate"
|
|
313
|
+
wrapperClassName="!block text-right"
|
|
314
|
+
>
|
|
315
|
+
<span className="cursor-help">CPU / Memory/mo*</span>
|
|
316
|
+
</Tooltip>
|
|
199
317
|
</div>
|
|
200
318
|
|
|
201
319
|
{/* Namespace rows */}
|
|
202
320
|
<div className="divide-y divide-theme-border/50">
|
|
203
321
|
{namespaces.map((ns) => (
|
|
204
|
-
<NamespaceCostRow
|
|
322
|
+
<NamespaceCostRow
|
|
323
|
+
key={ns.name}
|
|
324
|
+
ns={ns}
|
|
325
|
+
maxCost={namespaces[0]?.hourlyCost ?? 0}
|
|
326
|
+
hasStorage={hasStorage}
|
|
327
|
+
onOpenResource={onOpenResource}
|
|
328
|
+
/>
|
|
205
329
|
))}
|
|
206
330
|
</div>
|
|
207
331
|
</div>
|
|
208
332
|
|
|
209
333
|
{/* Node cost table */}
|
|
210
|
-
{nodes.length > 0 && <NodeCostTable nodes={nodes} />}
|
|
334
|
+
{nodes.length > 0 && <NodeCostTable nodes={nodes} onOpenResource={onOpenResource} />}
|
|
211
335
|
|
|
212
336
|
{/* Footer */}
|
|
213
337
|
<div className="flex items-center justify-between text-xs text-theme-text-tertiary pb-4">
|
|
214
338
|
<span>
|
|
215
|
-
{data.currency ?? 'USD'} ·
|
|
339
|
+
{data.currency ?? 'USD'} · current rates based on last 1h average ·
|
|
340
|
+
*monthly projections assume {COST_HOURS_PER_MONTH} hrs/mo
|
|
216
341
|
</span>
|
|
217
342
|
<span className="text-indigo-500 font-medium">Powered by OpenCost</span>
|
|
218
343
|
</div>
|
|
@@ -224,21 +349,43 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
224
349
|
)
|
|
225
350
|
}
|
|
226
351
|
|
|
227
|
-
function
|
|
352
|
+
function CostOverviewState({ children }: { children: React.ReactNode }) {
|
|
353
|
+
return (
|
|
354
|
+
<div className="flex-1 overflow-y-auto">
|
|
355
|
+
<div className="mx-auto flex w-full max-w-[1920px] flex-col gap-4 px-6 py-6">
|
|
356
|
+
<PageHeader
|
|
357
|
+
icon={DollarSign}
|
|
358
|
+
title="Cost Insights"
|
|
359
|
+
description="Understand current allocation and find CPU and memory requests worth tuning."
|
|
360
|
+
/>
|
|
361
|
+
<CostViewTabs />
|
|
362
|
+
{children}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function NamespaceCostRow({
|
|
369
|
+
ns,
|
|
370
|
+
maxCost,
|
|
371
|
+
hasStorage,
|
|
372
|
+
onOpenResource,
|
|
373
|
+
}: {
|
|
374
|
+
ns: OpenCostNamespaceCost
|
|
375
|
+
maxCost: number
|
|
376
|
+
hasStorage: boolean
|
|
377
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
378
|
+
}) {
|
|
228
379
|
const [expanded, setExpanded] = useState(false)
|
|
229
|
-
const monthlyCost = ns.hourlyCost * 730
|
|
230
380
|
const allocTotal = ns.cpuCost + ns.memoryCost + (ns.storageCost ?? 0)
|
|
231
381
|
const cpuPct = allocTotal > 0 ? (ns.cpuCost / allocTotal) * 100 : 50
|
|
232
382
|
const memPct = allocTotal > 0 ? (ns.memoryCost / allocTotal) * 100 : 50
|
|
233
383
|
const barWidth = maxCost > 0 ? (ns.hourlyCost / maxCost) * 100 : 0
|
|
234
|
-
const eff = ns.efficiency ?? 0
|
|
235
|
-
const hasEff = eff > 0
|
|
236
|
-
|
|
237
384
|
return (
|
|
238
385
|
<div>
|
|
239
386
|
<button
|
|
240
387
|
onClick={() => setExpanded(!expanded)}
|
|
241
|
-
className="w-full grid grid-cols-[minmax(180px,1fr)
|
|
388
|
+
className="w-full grid grid-cols-[minmax(180px,1fr)_110px_90px_minmax(160px,1fr)_150px] gap-2 px-4 py-2.5 text-left hover:bg-theme-hover/50 transition-colors group"
|
|
242
389
|
>
|
|
243
390
|
<span className="flex items-center gap-1.5 min-w-0">
|
|
244
391
|
{expanded ? (
|
|
@@ -246,46 +393,89 @@ function NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCo
|
|
|
246
393
|
) : (
|
|
247
394
|
<ChevronRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
|
|
248
395
|
)}
|
|
249
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
{
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
396
|
+
<Tooltip content={`Namespace ${ns.name}`} wrapperClassName="min-w-0">
|
|
397
|
+
<span className="block truncate text-sm text-theme-text-primary font-medium">
|
|
398
|
+
{ns.name}
|
|
399
|
+
</span>
|
|
400
|
+
</Tooltip>
|
|
401
|
+
{isSystemCostNamespace(ns.name) && (
|
|
402
|
+
<Tooltip
|
|
403
|
+
content="Usually platform overhead from Kubernetes and cluster add-ons. Review enabled add-ons before treating this as an application optimization target."
|
|
404
|
+
wrapperClassName="shrink-0"
|
|
405
|
+
>
|
|
406
|
+
<span className="rounded bg-theme-elevated px-1.5 py-0.5 text-[10px] font-medium text-theme-text-tertiary">
|
|
407
|
+
system
|
|
258
408
|
</span>
|
|
259
|
-
|
|
260
|
-
</>
|
|
261
|
-
) : (
|
|
262
|
-
<span className="text-xs text-theme-text-quaternary">-</span>
|
|
409
|
+
</Tooltip>
|
|
263
410
|
)}
|
|
264
411
|
</span>
|
|
412
|
+
<span className="text-sm font-medium text-theme-text-primary tabular-nums text-right">
|
|
413
|
+
{formatProjectedMonthlyCost(ns.hourlyCost)}
|
|
414
|
+
</span>
|
|
415
|
+
<span className="text-sm text-theme-text-secondary tabular-nums text-right">
|
|
416
|
+
{formatCostPerHour(ns.hourlyCost)}
|
|
417
|
+
</span>
|
|
265
418
|
<span className="flex items-center gap-2">
|
|
266
|
-
<div
|
|
267
|
-
|
|
268
|
-
|
|
419
|
+
<div
|
|
420
|
+
className="flex-1 h-2 rounded-full overflow-hidden bg-theme-hover flex"
|
|
421
|
+
style={{ maxWidth: `${Math.max(barWidth, 3)}%` }}
|
|
422
|
+
>
|
|
423
|
+
<div className="h-full bg-accent" style={{ width: `${cpuPct}%` }} />
|
|
424
|
+
<div className="h-full bg-amber-500" style={{ width: `${memPct}%` }} />
|
|
269
425
|
{hasStorage && (ns.storageCost ?? 0) > 0 && (
|
|
270
|
-
<div className="h-full bg-teal-500
|
|
426
|
+
<div className="h-full bg-teal-500" style={{ width: `${100 - cpuPct - memPct}%` }} />
|
|
271
427
|
)}
|
|
272
428
|
</div>
|
|
273
429
|
</span>
|
|
274
430
|
<span className="text-[11px] text-theme-text-tertiary tabular-nums text-right">
|
|
275
|
-
{
|
|
276
|
-
{hasStorage &&
|
|
431
|
+
{formatProjectedMonthlyCost(ns.cpuCost)} / {formatProjectedMonthlyCost(ns.memoryCost)}
|
|
432
|
+
{hasStorage &&
|
|
433
|
+
(ns.storageCost ?? 0) > 0 &&
|
|
434
|
+
` / ${formatProjectedMonthlyCost(ns.storageCost ?? 0)}`}
|
|
277
435
|
</span>
|
|
278
436
|
</button>
|
|
279
437
|
|
|
280
438
|
{/* Expanded workload rows */}
|
|
281
|
-
{expanded && (
|
|
282
|
-
<
|
|
439
|
+
{expanded && isSystemCostNamespace(ns.name) && (
|
|
440
|
+
<SystemNamespaceCostNote namespace={ns.name} />
|
|
283
441
|
)}
|
|
442
|
+
{expanded && <WorkloadRows namespace={ns.name} onOpenResource={onOpenResource} />}
|
|
284
443
|
</div>
|
|
285
444
|
)
|
|
286
445
|
}
|
|
287
446
|
|
|
288
|
-
function
|
|
447
|
+
function isSystemCostNamespace(namespace: string): boolean {
|
|
448
|
+
return SYSTEM_COST_NAMESPACES.has(namespace)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function SystemNamespaceCostNote({ namespace }: { namespace: string }) {
|
|
452
|
+
return (
|
|
453
|
+
<div className="border-t border-theme-border/30 bg-theme-elevated/30 px-10 py-2 text-xs text-theme-text-tertiary">
|
|
454
|
+
<span className="font-medium text-theme-text-secondary">{namespace}</span> is usually baseline
|
|
455
|
+
cluster overhead: Kubernetes components, cloud-provider agents, and installed add-ons.
|
|
456
|
+
Optimize by reviewing add-ons, logging, monitoring, or node count rather than deleting system
|
|
457
|
+
workloads directly.
|
|
458
|
+
</div>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function SystemNamespacesCostNote() {
|
|
463
|
+
return (
|
|
464
|
+
<div className="border-b border-theme-border/50 bg-theme-elevated/30 px-4 py-2 text-xs text-theme-text-tertiary">
|
|
465
|
+
Rows marked <span className="font-medium text-theme-text-secondary">system</span> are usually
|
|
466
|
+
baseline Kubernetes, cloud-provider, or add-on overhead. Optimize by reviewing enabled
|
|
467
|
+
add-ons, telemetry, or node count before treating them as application optimization targets.
|
|
468
|
+
</div>
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function WorkloadRows({
|
|
473
|
+
namespace,
|
|
474
|
+
onOpenResource,
|
|
475
|
+
}: {
|
|
476
|
+
namespace: string
|
|
477
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
478
|
+
}) {
|
|
289
479
|
const { data, isLoading } = useOpenCostWorkloads(namespace)
|
|
290
480
|
|
|
291
481
|
if (isLoading) {
|
|
@@ -309,57 +499,94 @@ function WorkloadRows({ namespace }: { namespace: string }) {
|
|
|
309
499
|
return (
|
|
310
500
|
<div className="bg-theme-elevated/30 border-t border-theme-border/30">
|
|
311
501
|
{workloads.map((wl) => (
|
|
312
|
-
<WorkloadCostRow
|
|
502
|
+
<WorkloadCostRow
|
|
503
|
+
key={`${wl.kind}-${wl.name}`}
|
|
504
|
+
wl={wl}
|
|
505
|
+
namespace={namespace}
|
|
506
|
+
maxCost={workloads[0]?.hourlyCost ?? 0}
|
|
507
|
+
onOpenResource={onOpenResource}
|
|
508
|
+
/>
|
|
313
509
|
))}
|
|
314
510
|
</div>
|
|
315
511
|
)
|
|
316
512
|
}
|
|
317
513
|
|
|
318
|
-
function WorkloadCostRow({
|
|
319
|
-
|
|
514
|
+
function WorkloadCostRow({
|
|
515
|
+
wl,
|
|
516
|
+
namespace,
|
|
517
|
+
maxCost,
|
|
518
|
+
onOpenResource,
|
|
519
|
+
}: {
|
|
520
|
+
wl: OpenCostWorkloadCost
|
|
521
|
+
namespace: string
|
|
522
|
+
maxCost: number
|
|
523
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
524
|
+
}) {
|
|
320
525
|
const cpuPct = wl.hourlyCost > 0 ? (wl.cpuCost / wl.hourlyCost) * 100 : 50
|
|
321
526
|
const barWidth = maxCost > 0 ? (wl.hourlyCost / maxCost) * 100 : 0
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
<div className="grid grid-cols-[minmax(180px,1fr)_90px_90px_80px_minmax(160px,1fr)_120px] gap-2 px-4 py-2 text-left">
|
|
527
|
+
const kindLabel = displayCostWorkloadKind(wl.kind)
|
|
528
|
+
const resource = costWorkloadResource(wl, namespace)
|
|
529
|
+
const canOpen = Boolean(resource && onOpenResource)
|
|
530
|
+
const content = (
|
|
531
|
+
<>
|
|
328
532
|
<span className="flex items-center gap-1.5 min-w-0 pl-5">
|
|
329
|
-
<span className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded shrink-0">
|
|
330
|
-
|
|
533
|
+
<span className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded shrink-0">
|
|
534
|
+
{kindLabel}
|
|
535
|
+
</span>
|
|
536
|
+
<Tooltip content={`${kindLabel} ${namespace}/${wl.name}`} wrapperClassName="min-w-0">
|
|
537
|
+
<span className="block truncate text-xs text-theme-text-secondary">{wl.name}</span>
|
|
538
|
+
</Tooltip>
|
|
331
539
|
{wl.replicas > 1 && (
|
|
332
540
|
<span className="text-[10px] text-theme-text-tertiary shrink-0">{wl.replicas}x</span>
|
|
333
541
|
)}
|
|
334
542
|
</span>
|
|
335
|
-
<span className="text-xs text-theme-text-secondary tabular-nums text-right">
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
<span className={`text-[10px] font-medium tabular-nums ${efficiencyColor(eff)}`}>
|
|
341
|
-
{eff.toFixed(0)}%
|
|
342
|
-
</span>
|
|
343
|
-
{eff < 25 && <span className="text-[9px] text-red-400">low</span>}
|
|
344
|
-
</>
|
|
345
|
-
) : (
|
|
346
|
-
<span className="text-[10px] text-theme-text-quaternary">-</span>
|
|
347
|
-
)}
|
|
543
|
+
<span className="text-xs font-medium text-theme-text-secondary tabular-nums text-right">
|
|
544
|
+
{formatProjectedMonthlyCost(wl.hourlyCost)}
|
|
545
|
+
</span>
|
|
546
|
+
<span className="text-xs text-theme-text-tertiary tabular-nums text-right">
|
|
547
|
+
{formatCostPerHour(wl.hourlyCost)}
|
|
348
548
|
</span>
|
|
349
549
|
<span className="flex items-center gap-2">
|
|
350
|
-
<div
|
|
351
|
-
|
|
352
|
-
|
|
550
|
+
<div
|
|
551
|
+
className="flex-1 h-1.5 rounded-full overflow-hidden bg-theme-hover flex"
|
|
552
|
+
style={{ maxWidth: `${Math.max(barWidth, 3)}%` }}
|
|
553
|
+
>
|
|
554
|
+
<div className="h-full bg-accent" style={{ width: `${cpuPct}%` }} />
|
|
555
|
+
<div className="h-full bg-amber-500" style={{ width: `${100 - cpuPct}%` }} />
|
|
353
556
|
</div>
|
|
354
557
|
</span>
|
|
355
558
|
<span className="text-[10px] text-theme-text-tertiary tabular-nums text-right">
|
|
356
|
-
{
|
|
559
|
+
{formatProjectedMonthlyCost(wl.cpuCost)} / {formatProjectedMonthlyCost(wl.memoryCost)}
|
|
357
560
|
</span>
|
|
358
|
-
|
|
561
|
+
</>
|
|
359
562
|
)
|
|
563
|
+
|
|
564
|
+
const rowClass =
|
|
565
|
+
'grid grid-cols-[minmax(180px,1fr)_110px_90px_minmax(160px,1fr)_150px] gap-2 px-4 py-2 text-left'
|
|
566
|
+
|
|
567
|
+
if (canOpen && resource) {
|
|
568
|
+
return (
|
|
569
|
+
<button
|
|
570
|
+
type="button"
|
|
571
|
+
onClick={() => onOpenResource?.(resource)}
|
|
572
|
+
className={`${rowClass} w-full transition-colors hover:bg-theme-hover/60 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-inset`}
|
|
573
|
+
aria-label={`Open ${kindLabel} ${wl.name}`}
|
|
574
|
+
>
|
|
575
|
+
{content}
|
|
576
|
+
</button>
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return <div className={rowClass}>{content}</div>
|
|
360
581
|
}
|
|
361
582
|
|
|
362
|
-
function NodeCostTable({
|
|
583
|
+
function NodeCostTable({
|
|
584
|
+
nodes,
|
|
585
|
+
onOpenResource,
|
|
586
|
+
}: {
|
|
587
|
+
nodes: OpenCostNodeCost[]
|
|
588
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
589
|
+
}) {
|
|
363
590
|
return (
|
|
364
591
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
365
592
|
<div className="px-4 py-3 border-b border-theme-border">
|
|
@@ -379,49 +606,127 @@ function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) {
|
|
|
379
606
|
</div>
|
|
380
607
|
|
|
381
608
|
{/* Table header */}
|
|
382
|
-
<div className="grid grid-cols-[minmax(200px,1fr)_minmax(120px,1fr)
|
|
609
|
+
<div className="grid grid-cols-[minmax(200px,1fr)_minmax(120px,1fr)_110px_90px_150px] gap-2 px-4 py-2 border-b border-theme-border text-[11px] font-medium text-theme-text-tertiary uppercase tracking-wider">
|
|
383
610
|
<span>Node</span>
|
|
384
611
|
<span>Instance Type</span>
|
|
612
|
+
<Tooltip
|
|
613
|
+
content="Projected from current hourly rate — not historical spend"
|
|
614
|
+
wrapperClassName="!block text-right"
|
|
615
|
+
>
|
|
616
|
+
<span className="cursor-help">Projected/mo*</span>
|
|
617
|
+
</Tooltip>
|
|
385
618
|
<span className="text-right">Hourly</span>
|
|
386
|
-
<Tooltip
|
|
387
|
-
|
|
619
|
+
<Tooltip
|
|
620
|
+
content="Projected monthly CPU and memory portions of this node's current price"
|
|
621
|
+
wrapperClassName="!block text-right"
|
|
622
|
+
>
|
|
623
|
+
<span className="cursor-help">CPU / Memory/mo*</span>
|
|
388
624
|
</Tooltip>
|
|
389
|
-
<span className="text-right">CPU / Memory</span>
|
|
390
625
|
</div>
|
|
391
626
|
|
|
392
627
|
{/* Node rows */}
|
|
393
628
|
<div className="divide-y divide-theme-border/50">
|
|
394
629
|
{nodes.map((node) => (
|
|
395
|
-
<NodeCostRow key={node.name} node={node} />
|
|
630
|
+
<NodeCostRow key={node.name} node={node} onOpenResource={onOpenResource} />
|
|
396
631
|
))}
|
|
397
632
|
</div>
|
|
398
633
|
</div>
|
|
399
634
|
)
|
|
400
635
|
}
|
|
401
636
|
|
|
402
|
-
function NodeCostRow({
|
|
403
|
-
|
|
637
|
+
function NodeCostRow({
|
|
638
|
+
node,
|
|
639
|
+
onOpenResource,
|
|
640
|
+
}: {
|
|
641
|
+
node: OpenCostNodeCost
|
|
642
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
643
|
+
}) {
|
|
644
|
+
const cloudLink = nodeCloudConsoleLink(node.providerID)
|
|
645
|
+
const openNode = () => onOpenResource?.({ kind: 'nodes', namespace: '', name: node.name })
|
|
404
646
|
|
|
405
647
|
return (
|
|
406
|
-
<div className="grid grid-cols-[minmax(200px,1fr)_minmax(120px,1fr)
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
648
|
+
<div className="grid grid-cols-[minmax(200px,1fr)_minmax(120px,1fr)_110px_90px_150px] gap-2 px-4 py-2.5">
|
|
649
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
650
|
+
<Tooltip content={`Open Node ${node.name}`} wrapperClassName="!block min-w-0">
|
|
651
|
+
{onOpenResource ? (
|
|
652
|
+
<button
|
|
653
|
+
type="button"
|
|
654
|
+
onClick={openNode}
|
|
655
|
+
className="block min-w-0 truncate text-left text-sm font-medium text-theme-text-primary transition-colors hover:text-accent-text focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1 focus:ring-offset-theme-base"
|
|
656
|
+
>
|
|
657
|
+
{node.name}
|
|
658
|
+
</button>
|
|
659
|
+
) : (
|
|
660
|
+
<span className="text-sm text-theme-text-primary truncate font-medium block">
|
|
661
|
+
{node.name}
|
|
662
|
+
</span>
|
|
663
|
+
)}
|
|
664
|
+
</Tooltip>
|
|
665
|
+
{cloudLink && (
|
|
666
|
+
<Tooltip content={cloudLink.label}>
|
|
667
|
+
<button
|
|
668
|
+
type="button"
|
|
669
|
+
onClick={() => openExternal(cloudLink.url)}
|
|
670
|
+
className="shrink-0 rounded p-0.5 text-theme-text-tertiary transition-colors hover:bg-theme-hover hover:text-accent-text focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1 focus:ring-offset-theme-base"
|
|
671
|
+
aria-label={`${cloudLink.label}: ${node.name}`}
|
|
672
|
+
>
|
|
673
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
674
|
+
</button>
|
|
675
|
+
</Tooltip>
|
|
676
|
+
)}
|
|
410
677
|
</span>
|
|
411
|
-
</Tooltip>
|
|
412
678
|
<span className="text-xs text-theme-text-secondary truncate">
|
|
413
679
|
{node.instanceType || '-'}
|
|
414
680
|
{node.region && <span className="text-theme-text-quaternary ml-1.5">({node.region})</span>}
|
|
415
681
|
</span>
|
|
416
|
-
<span className="text-sm text-theme-text-primary tabular-nums text-right">
|
|
417
|
-
|
|
682
|
+
<span className="text-sm font-medium text-theme-text-primary tabular-nums text-right">
|
|
683
|
+
{formatProjectedMonthlyCost(node.hourlyCost)}
|
|
684
|
+
</span>
|
|
685
|
+
<span className="text-sm text-theme-text-secondary tabular-nums text-right">
|
|
686
|
+
{formatCostPerHour(node.hourlyCost)}
|
|
687
|
+
</span>
|
|
418
688
|
<span className="text-[11px] text-theme-text-tertiary tabular-nums text-right">
|
|
419
|
-
{
|
|
689
|
+
{formatProjectedMonthlyCost(node.cpuCost)} / {formatProjectedMonthlyCost(node.memoryCost)}
|
|
420
690
|
</span>
|
|
421
691
|
</div>
|
|
422
692
|
)
|
|
423
693
|
}
|
|
424
694
|
|
|
695
|
+
function displayCostWorkloadKind(kind: string): string {
|
|
696
|
+
if (kind === 'standalone') return 'pod'
|
|
697
|
+
if (kind === 'staticpod') return 'static pod'
|
|
698
|
+
return kind
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function costWorkloadResource(
|
|
702
|
+
wl: OpenCostWorkloadCost,
|
|
703
|
+
namespace: string,
|
|
704
|
+
): SelectedResource | null {
|
|
705
|
+
const kind = resourceKindForCostWorkload(wl.kind)
|
|
706
|
+
if (!kind || !wl.name) return null
|
|
707
|
+
return {
|
|
708
|
+
kind: kindToPlural(kind),
|
|
709
|
+
namespace: kind === 'Node' ? '' : namespace,
|
|
710
|
+
name: wl.name,
|
|
711
|
+
group: apiGroupForCostWorkload(kind),
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function resourceKindForCostWorkload(kind: string): string | null {
|
|
716
|
+
if (kind === 'staticpod') return 'Pod'
|
|
717
|
+
if (kind === 'standalone') return null
|
|
718
|
+
if (kind === 'Deployment' || kind === 'StatefulSet' || kind === 'DaemonSet') return kind
|
|
719
|
+
if (kind === 'Job' || kind === 'CronJob') return kind
|
|
720
|
+
if (kind === 'Node') return kind
|
|
721
|
+
return null
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function apiGroupForCostWorkload(kind: string): string | undefined {
|
|
725
|
+
if (kind === 'Deployment' || kind === 'StatefulSet' || kind === 'DaemonSet') return 'apps'
|
|
726
|
+
if (kind === 'Job' || kind === 'CronJob') return 'batch'
|
|
727
|
+
return undefined
|
|
728
|
+
}
|
|
729
|
+
|
|
425
730
|
// --- Help dialog ---
|
|
426
731
|
|
|
427
732
|
function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
@@ -441,7 +746,9 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
|
441
746
|
<div className="flex items-center justify-between p-4 border-b border-theme-border sticky top-0 bg-theme-surface rounded-t-lg">
|
|
442
747
|
<div className="flex items-center gap-2">
|
|
443
748
|
<HelpCircle className="w-5 h-5 text-indigo-500" />
|
|
444
|
-
<h2 className="text-base font-semibold text-theme-text-primary">
|
|
749
|
+
<h2 className="text-base font-semibold text-theme-text-primary">
|
|
750
|
+
Understanding Cost Data
|
|
751
|
+
</h2>
|
|
445
752
|
</div>
|
|
446
753
|
<button
|
|
447
754
|
onClick={onClose}
|
|
@@ -454,77 +761,59 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
|
454
761
|
<div className="p-4 space-y-5 text-sm text-theme-text-secondary">
|
|
455
762
|
{/* Where costs come from */}
|
|
456
763
|
<section>
|
|
457
|
-
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
|
|
764
|
+
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
|
|
765
|
+
Where do these costs come from?
|
|
766
|
+
</h3>
|
|
458
767
|
<p>
|
|
459
|
-
Cost data comes from <strong>OpenCost</strong>, an open-source tool that combines your
|
|
460
|
-
pricing (how much each node costs per hour) with Kubernetes resource
|
|
461
|
-
a dollar value for each workload running on your
|
|
768
|
+
Cost data comes from <strong>OpenCost</strong>, an open-source tool that combines your
|
|
769
|
+
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.
|
|
462
772
|
</p>
|
|
463
773
|
</section>
|
|
464
774
|
|
|
465
775
|
{/* What costs represent */}
|
|
466
776
|
<section>
|
|
467
|
-
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
|
|
777
|
+
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
|
|
778
|
+
What do monthly, daily, and hourly cost mean?
|
|
779
|
+
</h3>
|
|
468
780
|
<p>
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
781
|
+
OpenCost assigns CPU and memory allocation using the greater of a workload's request
|
|
782
|
+
or observed use, then applies the node's cloud pricing. Allocation cost is therefore
|
|
783
|
+
useful for attribution, but it is not a direct measurement of request headroom.
|
|
472
784
|
</p>
|
|
473
785
|
<p className="mt-1.5">
|
|
474
|
-
|
|
475
|
-
|
|
786
|
+
Projected monthly and daily numbers multiply the current hourly allocation rate. They
|
|
787
|
+
are useful for budget impact, but they are not a historical invoice total. Historical
|
|
788
|
+
spend on application and workload tabs uses the selected range.
|
|
476
789
|
</p>
|
|
477
790
|
</section>
|
|
478
791
|
|
|
479
|
-
{/* Efficiency */}
|
|
480
|
-
<section>
|
|
481
|
-
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">What is efficiency?</h3>
|
|
482
|
-
<p>
|
|
483
|
-
Efficiency compares what you're <strong>actually using</strong> versus what you've <strong>reserved</strong>,
|
|
484
|
-
weighted by cost. If a namespace reserves $1/hr of resources but only uses $0.40 worth, it's 40% efficient —
|
|
485
|
-
the other $0.60/hr is idle capacity you're paying for but not using.
|
|
486
|
-
</p>
|
|
487
|
-
<p className="mt-2 text-theme-text-tertiary text-xs">
|
|
488
|
-
Some over-provisioning is normal and healthy — it gives your workloads room to handle
|
|
489
|
-
traffic spikes without running out of resources. Don't aim for 100%.
|
|
490
|
-
</p>
|
|
491
|
-
<div className="mt-2 space-y-1">
|
|
492
|
-
<div className="flex items-center gap-2">
|
|
493
|
-
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
|
494
|
-
<span><strong>50%+</strong> — well-utilized</span>
|
|
495
|
-
</div>
|
|
496
|
-
<div className="flex items-center gap-2">
|
|
497
|
-
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
|
498
|
-
<span><strong>25–50%</strong> — typical for most clusters, some room to optimize</span>
|
|
499
|
-
</div>
|
|
500
|
-
<div className="flex items-center gap-2">
|
|
501
|
-
<span className="w-2 h-2 rounded-full bg-red-400" />
|
|
502
|
-
<span><strong>Below 25%</strong> — worth investigating, may be significantly over-provisioned</span>
|
|
503
|
-
</div>
|
|
504
|
-
</div>
|
|
505
|
-
</section>
|
|
506
|
-
|
|
507
792
|
{/* Time context */}
|
|
508
793
|
<section>
|
|
509
|
-
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
|
|
794
|
+
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
|
|
795
|
+
How fresh is this data?
|
|
796
|
+
</h3>
|
|
510
797
|
<p>
|
|
511
|
-
Cost rates
|
|
512
|
-
They update automatically every minute. The trend chart
|
|
513
|
-
|
|
798
|
+
Cost rates and breakdowns are <strong>snapshots based on the last 1 hour</strong> of
|
|
799
|
+
data. They update automatically every minute. The trend chart shows historical hourly
|
|
800
|
+
allocation rate over the selected time range (6 hours, 24 hours, or 7 days).
|
|
514
801
|
</p>
|
|
515
802
|
<p className="mt-1.5">
|
|
516
|
-
Because costs are based on a 1-hour window, short-lived spikes or dips may not be
|
|
517
|
-
The trend chart gives you the longer-term picture.
|
|
803
|
+
Because costs are based on a 1-hour window, short-lived spikes or dips may not be
|
|
804
|
+
reflected. The trend chart gives you the longer-term rate picture.
|
|
518
805
|
</p>
|
|
519
806
|
</section>
|
|
520
807
|
|
|
521
808
|
{/* Node costs */}
|
|
522
809
|
<section>
|
|
523
|
-
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
|
|
810
|
+
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">
|
|
811
|
+
What are node costs?
|
|
812
|
+
</h3>
|
|
524
813
|
<p>
|
|
525
|
-
Node costs show the hourly price of each machine in your cluster, based on instance
|
|
526
|
-
cloud pricing. This is the total capacity cost — the namespace and workload
|
|
527
|
-
show how that capacity is allocated across your workloads.
|
|
814
|
+
Node costs show the hourly price of each machine in your cluster, based on instance
|
|
815
|
+
type and cloud pricing. This is the total capacity cost — the namespace and workload
|
|
816
|
+
breakdowns above show how that capacity is allocated across your workloads.
|
|
528
817
|
</p>
|
|
529
818
|
</section>
|
|
530
819
|
</div>
|
|
@@ -532,27 +821,3 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
|
532
821
|
</div>
|
|
533
822
|
)
|
|
534
823
|
}
|
|
535
|
-
|
|
536
|
-
// --- Utilities ---
|
|
537
|
-
|
|
538
|
-
function efficiencyColor(efficiency: number): string {
|
|
539
|
-
if (efficiency >= 50) return 'text-emerald-400'
|
|
540
|
-
if (efficiency >= 25) return 'text-amber-400'
|
|
541
|
-
return 'text-red-400'
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
function formatCost(value: number): string {
|
|
545
|
-
if (value >= 1000) {
|
|
546
|
-
return `$${(value / 1000).toFixed(1)}k`
|
|
547
|
-
}
|
|
548
|
-
if (value >= 1) {
|
|
549
|
-
return `$${value.toFixed(2)}`
|
|
550
|
-
}
|
|
551
|
-
if (value >= 0.01) {
|
|
552
|
-
return `$${value.toFixed(3)}`
|
|
553
|
-
}
|
|
554
|
-
if (value > 0) {
|
|
555
|
-
return `$${value.toFixed(4)}`
|
|
556
|
-
}
|
|
557
|
-
return '$0.00'
|
|
558
|
-
}
|