@skyhook-io/radar-app 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- 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/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- 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 +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -1,57 +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'
|
|
34
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
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'
|
|
7
41
|
|
|
8
42
|
interface CostViewProps {
|
|
9
43
|
onBack: () => void
|
|
44
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
45
|
+
namespaces: string[]
|
|
10
46
|
}
|
|
11
47
|
|
|
12
|
-
|
|
13
|
-
|
|
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()
|
|
14
60
|
const { data: nodeData } = useOpenCostNodes()
|
|
61
|
+
const { data: clusterInfo } = useClusterInfo()
|
|
62
|
+
const { connection } = useConnection()
|
|
15
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])
|
|
16
73
|
|
|
17
74
|
if (isLoading) {
|
|
18
|
-
return
|
|
75
|
+
return (
|
|
76
|
+
<CostOverviewState>
|
|
77
|
+
<PaneLoader label="Loading cost data…" className="min-h-64" />
|
|
78
|
+
</CostOverviewState>
|
|
79
|
+
)
|
|
19
80
|
}
|
|
20
81
|
|
|
21
82
|
if (!data || !data.available) {
|
|
22
83
|
const reason = data?.reason
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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'
|
|
30
123
|
|
|
31
124
|
return (
|
|
32
|
-
<
|
|
33
|
-
<div className="flex
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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>
|
|
42
149
|
</div>
|
|
43
|
-
</
|
|
150
|
+
</CostOverviewState>
|
|
44
151
|
)
|
|
45
152
|
}
|
|
46
153
|
|
|
47
154
|
const hourlyCost = data.totalHourlyCost ?? 0
|
|
48
|
-
const monthlyCost = hourlyCost * 730
|
|
49
155
|
const namespaces = data.namespaces ?? []
|
|
50
156
|
const totalCpu = namespaces.reduce((sum, ns) => sum + ns.cpuCost, 0)
|
|
51
157
|
const totalMem = namespaces.reduce((sum, ns) => sum + ns.memoryCost, 0)
|
|
52
158
|
const totalStorage = data.totalStorageCost ?? 0
|
|
53
159
|
const hasStorage = totalStorage > 0
|
|
54
|
-
const
|
|
160
|
+
const hasSystemNamespaces = namespaces.some((ns) => isSystemCostNamespace(ns.name))
|
|
55
161
|
|
|
56
162
|
// Compute split percentages (CPU + Memory + optional Storage)
|
|
57
163
|
const allocTotal = totalCpu + totalMem + totalStorage
|
|
@@ -59,22 +165,15 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
59
165
|
const memPct = allocTotal > 0 ? (totalMem / allocTotal) * 100 : 50
|
|
60
166
|
const storagePct = allocTotal > 0 ? (totalStorage / allocTotal) * 100 : 0
|
|
61
167
|
|
|
62
|
-
const nodes = nodeData?.available ? nodeData.nodes ?? [] : []
|
|
168
|
+
const nodes = nodeData?.available ? (nodeData.nodes ?? []) : []
|
|
169
|
+
const clusterConsoleLink = clusterCloudConsoleLink(clusterInfo?.context)
|
|
63
170
|
|
|
64
171
|
return (
|
|
65
172
|
<div className="flex-1 overflow-y-auto">
|
|
66
|
-
<div className="max-w-[
|
|
173
|
+
<div className="mx-auto w-full max-w-[1920px] px-6 py-6 space-y-6">
|
|
67
174
|
{/* Header */}
|
|
68
175
|
<div className="flex items-center justify-between">
|
|
69
176
|
<div className="flex items-center gap-3">
|
|
70
|
-
<button
|
|
71
|
-
onClick={onBack}
|
|
72
|
-
className="flex items-center gap-1.5 text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
73
|
-
>
|
|
74
|
-
<ArrowLeft className="w-4 h-4" />
|
|
75
|
-
Dashboard
|
|
76
|
-
</button>
|
|
77
|
-
<div className="w-px h-5 bg-theme-border" />
|
|
78
177
|
<div className="flex items-center gap-2">
|
|
79
178
|
<DollarSign className="w-5 h-5 text-indigo-500" />
|
|
80
179
|
<h1 className="text-lg font-semibold text-theme-text-primary">Cost Insights</h1>
|
|
@@ -87,66 +186,84 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
87
186
|
<HelpCircle className="w-3.5 h-3.5" />
|
|
88
187
|
How this works
|
|
89
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
|
+
)}
|
|
90
202
|
</div>
|
|
91
203
|
<div className="flex items-center gap-4">
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
204
|
+
{/* Tracks the headline monthly summary (the primary query); its load
|
|
205
|
+
time is the representative freshness signal for the view. */}
|
|
206
|
+
<FreshnessControl
|
|
207
|
+
mode="auto"
|
|
208
|
+
dataUpdatedAt={dataUpdatedAt}
|
|
209
|
+
onRefresh={() => refetch()}
|
|
210
|
+
connectionState={connection.state}
|
|
211
|
+
/>
|
|
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)}
|
|
101
216
|
</span>
|
|
217
|
+
<span className="text-xs text-theme-text-tertiary">/mo</span>
|
|
102
218
|
</div>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
</div>
|
|
112
|
-
<div className="flex items-baseline gap-1 text-theme-text-secondary">
|
|
113
|
-
<span className="text-sm font-medium tabular-nums">~{formatCost(monthlyCost)}</span>
|
|
114
|
-
<span className="text-[10px] text-theme-text-tertiary">/mo</span>
|
|
115
|
-
</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>
|
|
116
227
|
</div>
|
|
117
|
-
<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>
|
|
118
231
|
</div>
|
|
119
232
|
</div>
|
|
120
233
|
</div>
|
|
121
234
|
|
|
235
|
+
<CostViewTabs />
|
|
236
|
+
|
|
122
237
|
{/* CPU vs Memory (vs Storage) split bar */}
|
|
123
238
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
124
239
|
<div className="flex items-center justify-between mb-2">
|
|
125
|
-
<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>
|
|
126
243
|
<div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
|
|
127
244
|
<span className="flex items-center gap-1.5">
|
|
128
|
-
<span className="w-2.5 h-2.5 rounded-sm bg-
|
|
129
|
-
CPU {
|
|
245
|
+
<span className="w-2.5 h-2.5 rounded-sm bg-accent" />
|
|
246
|
+
CPU {formatProjectedMonthlyRate(totalCpu)}
|
|
130
247
|
</span>
|
|
131
248
|
<span className="flex items-center gap-1.5">
|
|
132
|
-
<span className="w-2.5 h-2.5 rounded-sm bg-
|
|
133
|
-
Memory {
|
|
249
|
+
<span className="w-2.5 h-2.5 rounded-sm bg-amber-500" />
|
|
250
|
+
Memory {formatProjectedMonthlyRate(totalMem)}
|
|
134
251
|
</span>
|
|
135
252
|
{hasStorage && (
|
|
136
253
|
<span className="flex items-center gap-1.5">
|
|
137
254
|
<span className="w-2.5 h-2.5 rounded-sm bg-teal-500" />
|
|
138
|
-
Storage {
|
|
255
|
+
Storage {formatProjectedMonthlyRate(totalStorage)}
|
|
139
256
|
</span>
|
|
140
257
|
)}
|
|
141
258
|
</div>
|
|
142
259
|
</div>
|
|
143
260
|
<div className="h-3 rounded-full overflow-hidden bg-theme-hover flex">
|
|
144
261
|
<div
|
|
145
|
-
className="h-full bg-
|
|
262
|
+
className="h-full bg-accent transition-all duration-300"
|
|
146
263
|
style={{ width: `${cpuPct}%` }}
|
|
147
264
|
/>
|
|
148
265
|
<div
|
|
149
|
-
className="h-full bg-
|
|
266
|
+
className="h-full bg-amber-500 transition-all duration-300"
|
|
150
267
|
style={{ width: `${memPct}%` }}
|
|
151
268
|
/>
|
|
152
269
|
{hasStorage && (
|
|
@@ -166,38 +283,61 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
166
283
|
<div className="px-4 py-3 border-b border-theme-border">
|
|
167
284
|
<div className="flex items-center justify-between">
|
|
168
285
|
<div>
|
|
169
|
-
<span className="text-sm font-semibold text-theme-text-primary">
|
|
170
|
-
|
|
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>
|
|
171
292
|
</div>
|
|
172
|
-
<span className="text-xs text-theme-text-tertiary">
|
|
293
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
294
|
+
{namespaces.length} namespaces
|
|
295
|
+
</span>
|
|
173
296
|
</div>
|
|
174
297
|
</div>
|
|
298
|
+
{hasSystemNamespaces && <SystemNamespacesCostNote />}
|
|
175
299
|
|
|
176
300
|
{/* Table header */}
|
|
177
|
-
<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">
|
|
178
302
|
<span>Namespace</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>
|
|
308
|
+
</Tooltip>
|
|
179
309
|
<span className="text-right">Hourly</span>
|
|
180
|
-
<span className="text-right cursor-help" title="Projected from current hourly rate — not historical spend">Monthly*</span>
|
|
181
|
-
<span className="text-right cursor-help" title="% of reserved resources actually being used, weighted by cost">Efficiency</span>
|
|
182
310
|
<span>CPU / Memory</span>
|
|
183
|
-
<
|
|
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>
|
|
184
317
|
</div>
|
|
185
318
|
|
|
186
319
|
{/* Namespace rows */}
|
|
187
320
|
<div className="divide-y divide-theme-border/50">
|
|
188
321
|
{namespaces.map((ns) => (
|
|
189
|
-
<NamespaceCostRow
|
|
322
|
+
<NamespaceCostRow
|
|
323
|
+
key={ns.name}
|
|
324
|
+
ns={ns}
|
|
325
|
+
maxCost={namespaces[0]?.hourlyCost ?? 0}
|
|
326
|
+
hasStorage={hasStorage}
|
|
327
|
+
onOpenResource={onOpenResource}
|
|
328
|
+
/>
|
|
190
329
|
))}
|
|
191
330
|
</div>
|
|
192
331
|
</div>
|
|
193
332
|
|
|
194
333
|
{/* Node cost table */}
|
|
195
|
-
{nodes.length > 0 && <NodeCostTable nodes={nodes} />}
|
|
334
|
+
{nodes.length > 0 && <NodeCostTable nodes={nodes} onOpenResource={onOpenResource} />}
|
|
196
335
|
|
|
197
336
|
{/* Footer */}
|
|
198
337
|
<div className="flex items-center justify-between text-xs text-theme-text-tertiary pb-4">
|
|
199
338
|
<span>
|
|
200
|
-
{data.currency ?? 'USD'} ·
|
|
339
|
+
{data.currency ?? 'USD'} · current rates based on last 1h average ·
|
|
340
|
+
*monthly projections assume {COST_HOURS_PER_MONTH} hrs/mo
|
|
201
341
|
</span>
|
|
202
342
|
<span className="text-indigo-500 font-medium">Powered by OpenCost</span>
|
|
203
343
|
</div>
|
|
@@ -209,21 +349,43 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
209
349
|
)
|
|
210
350
|
}
|
|
211
351
|
|
|
212
|
-
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
|
+
}) {
|
|
213
379
|
const [expanded, setExpanded] = useState(false)
|
|
214
|
-
const monthlyCost = ns.hourlyCost * 730
|
|
215
380
|
const allocTotal = ns.cpuCost + ns.memoryCost + (ns.storageCost ?? 0)
|
|
216
381
|
const cpuPct = allocTotal > 0 ? (ns.cpuCost / allocTotal) * 100 : 50
|
|
217
382
|
const memPct = allocTotal > 0 ? (ns.memoryCost / allocTotal) * 100 : 50
|
|
218
383
|
const barWidth = maxCost > 0 ? (ns.hourlyCost / maxCost) * 100 : 0
|
|
219
|
-
const eff = ns.efficiency ?? 0
|
|
220
|
-
const hasEff = eff > 0
|
|
221
|
-
|
|
222
384
|
return (
|
|
223
385
|
<div>
|
|
224
386
|
<button
|
|
225
387
|
onClick={() => setExpanded(!expanded)}
|
|
226
|
-
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"
|
|
227
389
|
>
|
|
228
390
|
<span className="flex items-center gap-1.5 min-w-0">
|
|
229
391
|
{expanded ? (
|
|
@@ -231,53 +393,96 @@ function NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCo
|
|
|
231
393
|
) : (
|
|
232
394
|
<ChevronRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
|
|
233
395
|
)}
|
|
234
|
-
<
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
243
408
|
</span>
|
|
244
|
-
|
|
245
|
-
</>
|
|
246
|
-
) : (
|
|
247
|
-
<span className="text-xs text-theme-text-quaternary">-</span>
|
|
409
|
+
</Tooltip>
|
|
248
410
|
)}
|
|
249
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>
|
|
250
418
|
<span className="flex items-center gap-2">
|
|
251
|
-
<div
|
|
252
|
-
|
|
253
|
-
|
|
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}%` }} />
|
|
254
425
|
{hasStorage && (ns.storageCost ?? 0) > 0 && (
|
|
255
|
-
<div className="h-full bg-teal-500
|
|
426
|
+
<div className="h-full bg-teal-500" style={{ width: `${100 - cpuPct - memPct}%` }} />
|
|
256
427
|
)}
|
|
257
428
|
</div>
|
|
258
429
|
</span>
|
|
259
430
|
<span className="text-[11px] text-theme-text-tertiary tabular-nums text-right">
|
|
260
|
-
{
|
|
261
|
-
{hasStorage &&
|
|
431
|
+
{formatProjectedMonthlyCost(ns.cpuCost)} / {formatProjectedMonthlyCost(ns.memoryCost)}
|
|
432
|
+
{hasStorage &&
|
|
433
|
+
(ns.storageCost ?? 0) > 0 &&
|
|
434
|
+
` / ${formatProjectedMonthlyCost(ns.storageCost ?? 0)}`}
|
|
262
435
|
</span>
|
|
263
436
|
</button>
|
|
264
437
|
|
|
265
438
|
{/* Expanded workload rows */}
|
|
266
|
-
{expanded && (
|
|
267
|
-
<
|
|
439
|
+
{expanded && isSystemCostNamespace(ns.name) && (
|
|
440
|
+
<SystemNamespaceCostNote namespace={ns.name} />
|
|
268
441
|
)}
|
|
442
|
+
{expanded && <WorkloadRows namespace={ns.name} onOpenResource={onOpenResource} />}
|
|
443
|
+
</div>
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
|
|
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.
|
|
269
468
|
</div>
|
|
270
469
|
)
|
|
271
470
|
}
|
|
272
471
|
|
|
273
|
-
function WorkloadRows({
|
|
472
|
+
function WorkloadRows({
|
|
473
|
+
namespace,
|
|
474
|
+
onOpenResource,
|
|
475
|
+
}: {
|
|
476
|
+
namespace: string
|
|
477
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
478
|
+
}) {
|
|
274
479
|
const { data, isLoading } = useOpenCostWorkloads(namespace)
|
|
275
480
|
|
|
276
481
|
if (isLoading) {
|
|
277
482
|
return (
|
|
278
483
|
<div className="px-4 py-3 flex items-center gap-2 text-xs text-theme-text-tertiary bg-theme-elevated/30">
|
|
279
484
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
280
|
-
Loading workloads
|
|
485
|
+
Loading workloads…
|
|
281
486
|
</div>
|
|
282
487
|
)
|
|
283
488
|
}
|
|
@@ -294,57 +499,94 @@ function WorkloadRows({ namespace }: { namespace: string }) {
|
|
|
294
499
|
return (
|
|
295
500
|
<div className="bg-theme-elevated/30 border-t border-theme-border/30">
|
|
296
501
|
{workloads.map((wl) => (
|
|
297
|
-
<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
|
+
/>
|
|
298
509
|
))}
|
|
299
510
|
</div>
|
|
300
511
|
)
|
|
301
512
|
}
|
|
302
513
|
|
|
303
|
-
function WorkloadCostRow({
|
|
304
|
-
|
|
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
|
+
}) {
|
|
305
525
|
const cpuPct = wl.hourlyCost > 0 ? (wl.cpuCost / wl.hourlyCost) * 100 : 50
|
|
306
526
|
const barWidth = maxCost > 0 ? (wl.hourlyCost / maxCost) * 100 : 0
|
|
307
|
-
const
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
<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
|
+
<>
|
|
313
532
|
<span className="flex items-center gap-1.5 min-w-0 pl-5">
|
|
314
|
-
<span className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded shrink-0">
|
|
315
|
-
|
|
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>
|
|
316
539
|
{wl.replicas > 1 && (
|
|
317
540
|
<span className="text-[10px] text-theme-text-tertiary shrink-0">{wl.replicas}x</span>
|
|
318
541
|
)}
|
|
319
542
|
</span>
|
|
320
|
-
<span className="text-xs text-theme-text-secondary tabular-nums text-right">
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
<span className={`text-[10px] font-medium tabular-nums ${efficiencyColor(eff)}`}>
|
|
326
|
-
{eff.toFixed(0)}%
|
|
327
|
-
</span>
|
|
328
|
-
{eff < 25 && <span className="text-[9px] text-red-400">low</span>}
|
|
329
|
-
</>
|
|
330
|
-
) : (
|
|
331
|
-
<span className="text-[10px] text-theme-text-quaternary">-</span>
|
|
332
|
-
)}
|
|
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)}
|
|
333
548
|
</span>
|
|
334
549
|
<span className="flex items-center gap-2">
|
|
335
|
-
<div
|
|
336
|
-
|
|
337
|
-
|
|
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}%` }} />
|
|
338
556
|
</div>
|
|
339
557
|
</span>
|
|
340
558
|
<span className="text-[10px] text-theme-text-tertiary tabular-nums text-right">
|
|
341
|
-
{
|
|
559
|
+
{formatProjectedMonthlyCost(wl.cpuCost)} / {formatProjectedMonthlyCost(wl.memoryCost)}
|
|
342
560
|
</span>
|
|
343
|
-
|
|
561
|
+
</>
|
|
344
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>
|
|
345
581
|
}
|
|
346
582
|
|
|
347
|
-
function NodeCostTable({
|
|
583
|
+
function NodeCostTable({
|
|
584
|
+
nodes,
|
|
585
|
+
onOpenResource,
|
|
586
|
+
}: {
|
|
587
|
+
nodes: OpenCostNodeCost[]
|
|
588
|
+
onOpenResource?: (resource: SelectedResource) => void
|
|
589
|
+
}) {
|
|
348
590
|
return (
|
|
349
591
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
350
592
|
<div className="px-4 py-3 border-b border-theme-border">
|
|
@@ -364,45 +606,127 @@ function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) {
|
|
|
364
606
|
</div>
|
|
365
607
|
|
|
366
608
|
{/* Table header */}
|
|
367
|
-
<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">
|
|
368
610
|
<span>Node</span>
|
|
369
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>
|
|
370
618
|
<span className="text-right">Hourly</span>
|
|
371
|
-
<
|
|
372
|
-
|
|
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>
|
|
624
|
+
</Tooltip>
|
|
373
625
|
</div>
|
|
374
626
|
|
|
375
627
|
{/* Node rows */}
|
|
376
628
|
<div className="divide-y divide-theme-border/50">
|
|
377
629
|
{nodes.map((node) => (
|
|
378
|
-
<NodeCostRow key={node.name} node={node} />
|
|
630
|
+
<NodeCostRow key={node.name} node={node} onOpenResource={onOpenResource} />
|
|
379
631
|
))}
|
|
380
632
|
</div>
|
|
381
633
|
</div>
|
|
382
634
|
)
|
|
383
635
|
}
|
|
384
636
|
|
|
385
|
-
function NodeCostRow({
|
|
386
|
-
|
|
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 })
|
|
387
646
|
|
|
388
647
|
return (
|
|
389
|
-
<div className="grid grid-cols-[minmax(200px,1fr)_minmax(120px,1fr)
|
|
390
|
-
<span className="
|
|
391
|
-
{node.name}
|
|
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
|
+
)}
|
|
392
677
|
</span>
|
|
393
678
|
<span className="text-xs text-theme-text-secondary truncate">
|
|
394
679
|
{node.instanceType || '-'}
|
|
395
680
|
{node.region && <span className="text-theme-text-quaternary ml-1.5">({node.region})</span>}
|
|
396
681
|
</span>
|
|
397
|
-
<span className="text-sm text-theme-text-primary tabular-nums text-right">
|
|
398
|
-
|
|
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>
|
|
399
688
|
<span className="text-[11px] text-theme-text-tertiary tabular-nums text-right">
|
|
400
|
-
{
|
|
689
|
+
{formatProjectedMonthlyCost(node.cpuCost)} / {formatProjectedMonthlyCost(node.memoryCost)}
|
|
401
690
|
</span>
|
|
402
691
|
</div>
|
|
403
692
|
)
|
|
404
693
|
}
|
|
405
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
|
+
|
|
406
730
|
// --- Help dialog ---
|
|
407
731
|
|
|
408
732
|
function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
@@ -417,12 +741,14 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
|
417
741
|
return (
|
|
418
742
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
419
743
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
420
|
-
<div className="relative dialog max-w-
|
|
744
|
+
<div className="relative dialog max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
|
421
745
|
{/* Header */}
|
|
422
746
|
<div className="flex items-center justify-between p-4 border-b border-theme-border sticky top-0 bg-theme-surface rounded-t-lg">
|
|
423
747
|
<div className="flex items-center gap-2">
|
|
424
748
|
<HelpCircle className="w-5 h-5 text-indigo-500" />
|
|
425
|
-
<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>
|
|
426
752
|
</div>
|
|
427
753
|
<button
|
|
428
754
|
onClick={onClose}
|
|
@@ -435,77 +761,59 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
|
435
761
|
<div className="p-4 space-y-5 text-sm text-theme-text-secondary">
|
|
436
762
|
{/* Where costs come from */}
|
|
437
763
|
<section>
|
|
438
|
-
<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>
|
|
439
767
|
<p>
|
|
440
|
-
Cost data comes from <strong>OpenCost</strong>, an open-source tool that combines your
|
|
441
|
-
pricing (how much each node costs per hour) with Kubernetes resource
|
|
442
|
-
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.
|
|
443
772
|
</p>
|
|
444
773
|
</section>
|
|
445
774
|
|
|
446
775
|
{/* What costs represent */}
|
|
447
776
|
<section>
|
|
448
|
-
<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>
|
|
449
780
|
<p>
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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.
|
|
453
784
|
</p>
|
|
454
785
|
<p className="mt-1.5">
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
</section>
|
|
459
|
-
|
|
460
|
-
{/* Efficiency */}
|
|
461
|
-
<section>
|
|
462
|
-
<h3 className="text-sm font-semibold text-theme-text-primary mb-1.5">What is efficiency?</h3>
|
|
463
|
-
<p>
|
|
464
|
-
Efficiency compares what you're <strong>actually using</strong> versus what you've <strong>reserved</strong>,
|
|
465
|
-
weighted by cost. If a namespace reserves $1/hr of resources but only uses $0.40 worth, it's 40% efficient —
|
|
466
|
-
the other $0.60/hr is idle capacity you're paying for but not using.
|
|
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.
|
|
467
789
|
</p>
|
|
468
|
-
<p className="mt-2 text-theme-text-tertiary text-xs">
|
|
469
|
-
Some over-provisioning is normal and healthy — it gives your workloads room to handle
|
|
470
|
-
traffic spikes without running out of resources. Don't aim for 100%.
|
|
471
|
-
</p>
|
|
472
|
-
<div className="mt-2 space-y-1">
|
|
473
|
-
<div className="flex items-center gap-2">
|
|
474
|
-
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
|
475
|
-
<span><strong>50%+</strong> — well-utilized</span>
|
|
476
|
-
</div>
|
|
477
|
-
<div className="flex items-center gap-2">
|
|
478
|
-
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
|
479
|
-
<span><strong>25–50%</strong> — typical for most clusters, some room to optimize</span>
|
|
480
|
-
</div>
|
|
481
|
-
<div className="flex items-center gap-2">
|
|
482
|
-
<span className="w-2 h-2 rounded-full bg-red-400" />
|
|
483
|
-
<span><strong>Below 25%</strong> — worth investigating, may be significantly over-provisioned</span>
|
|
484
|
-
</div>
|
|
485
|
-
</div>
|
|
486
790
|
</section>
|
|
487
791
|
|
|
488
792
|
{/* Time context */}
|
|
489
793
|
<section>
|
|
490
|
-
<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>
|
|
491
797
|
<p>
|
|
492
|
-
Cost rates
|
|
493
|
-
They update automatically every minute. The trend chart
|
|
494
|
-
|
|
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).
|
|
495
801
|
</p>
|
|
496
802
|
<p className="mt-1.5">
|
|
497
|
-
Because costs are based on a 1-hour window, short-lived spikes or dips may not be
|
|
498
|
-
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.
|
|
499
805
|
</p>
|
|
500
806
|
</section>
|
|
501
807
|
|
|
502
808
|
{/* Node costs */}
|
|
503
809
|
<section>
|
|
504
|
-
<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>
|
|
505
813
|
<p>
|
|
506
|
-
Node costs show the hourly price of each machine in your cluster, based on instance
|
|
507
|
-
cloud pricing. This is the total capacity cost — the namespace and workload
|
|
508
|
-
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.
|
|
509
817
|
</p>
|
|
510
818
|
</section>
|
|
511
819
|
</div>
|
|
@@ -513,27 +821,3 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
|
513
821
|
</div>
|
|
514
822
|
)
|
|
515
823
|
}
|
|
516
|
-
|
|
517
|
-
// --- Utilities ---
|
|
518
|
-
|
|
519
|
-
function efficiencyColor(efficiency: number): string {
|
|
520
|
-
if (efficiency >= 50) return 'text-emerald-400'
|
|
521
|
-
if (efficiency >= 25) return 'text-amber-400'
|
|
522
|
-
return 'text-red-400'
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
function formatCost(value: number): string {
|
|
526
|
-
if (value >= 1000) {
|
|
527
|
-
return `$${(value / 1000).toFixed(1)}k`
|
|
528
|
-
}
|
|
529
|
-
if (value >= 1) {
|
|
530
|
-
return `$${value.toFixed(2)}`
|
|
531
|
-
}
|
|
532
|
-
if (value >= 0.01) {
|
|
533
|
-
return `$${value.toFixed(3)}`
|
|
534
|
-
}
|
|
535
|
-
if (value > 0) {
|
|
536
|
-
return `$${value.toFixed(4)}`
|
|
537
|
-
}
|
|
538
|
-
return '$0.00'
|
|
539
|
-
}
|