@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.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,57 +1,163 @@
1
1
  import { useState, useEffect } from 'react'
2
- import { useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client'
3
- import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } from '../../api/client'
4
- import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react'
5
- import { PaneLoader } from '@skyhook-io/k8s-ui'
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
- export function CostView({ onBack }: CostViewProps) {
13
- const { data, isLoading } = useOpenCostSummary()
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 <PaneLoader label="Loading cost data…" className="flex-1" />
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 message = reason === 'no_prometheus'
24
- ? 'Prometheus not found OpenCost requires Prometheus or VictoriaMetrics'
25
- : reason === 'no_metrics'
26
- ? 'OpenCost metrics not found — Prometheus is available but no cost metrics were detected'
27
- : reason === 'query_error'
28
- ? 'Cost data temporarily unavailable Prometheus was found but queries failed'
29
- : 'OpenCost not detected install OpenCost for cost visibility'
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
- <div className="flex-1 flex items-center justify-center">
33
- <div className="flex flex-col items-center gap-3 text-theme-text-secondary">
34
- <DollarSign className="w-8 h-8 text-theme-text-tertiary/40" />
35
- <p className="text-sm">{message}</p>
36
- <button
37
- onClick={onBack}
38
- className="text-xs text-skyhook-400 hover:text-skyhook-300 transition-colors"
39
- >
40
- Back to Dashboard
41
- </button>
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
- </div>
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 hasEfficiency = (data.clusterEfficiency ?? 0) > 0
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-[1100px] mx-auto px-6 py-6 space-y-6">
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
- {hasEfficiency && (
93
- <div className="flex flex-col items-end gap-0.5">
94
- <div className="flex items-center gap-2 text-sm">
95
- <span className={efficiencyColor(data.clusterEfficiency ?? 0)}>
96
- {(data.clusterEfficiency ?? 0).toFixed(0)}% efficient
97
- </span>
98
- </div>
99
- <span className="text-[10px] text-theme-text-tertiary">
100
- ~{formatCost((data.totalIdleCost ?? 0) * 730)}/mo unused capacity
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
- <div className="flex flex-col items-end">
105
- <div className="flex items-baseline gap-3">
106
- <div className="flex items-baseline gap-1">
107
- <span className="text-2xl font-bold text-theme-text-primary tabular-nums">
108
- {formatCost(hourlyCost)}
109
- </span>
110
- <span className="text-xs text-theme-text-tertiary">/hr</span>
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">based on last 1h average</span>
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">Cluster Resource Cost</span>
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-blue-500" />
129
- CPU {formatCost(totalCpu)}/hr
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-purple-500" />
133
- Memory {formatCost(totalMem)}/hr
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 {formatCost(totalStorage)}/hr
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-blue-500 transition-all duration-300"
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-purple-500 transition-all duration-300"
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">Namespace Breakdown</span>
170
- <span className="text-[10px] text-theme-text-quaternary ml-2">current hourly rates</span>
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">{namespaces.length} namespaces</span>
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)_90px_90px_80px_minmax(160px,1fr)_120px] gap-2 px-4 py-2 border-b border-theme-border text-[11px] font-medium text-theme-text-tertiary uppercase tracking-wider">
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
- <span className="text-right">Cost Split</span>
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 key={ns.name} ns={ns} maxCost={namespaces[0]?.hourlyCost ?? 0} hasStorage={hasStorage} />
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'} &middot; costs based on last 1h average &middot; *monthly estimates assume 730 hrs/mo
339
+ {data.currency ?? 'USD'} &middot; current rates based on last 1h average &middot;
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 NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCost; maxCost: number; hasStorage: boolean }) {
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)_90px_90px_80px_minmax(160px,1fr)_120px] gap-2 px-4 py-2.5 text-left hover:bg-theme-hover/50 transition-colors group"
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
- <span className="text-sm text-theme-text-primary truncate font-medium">{ns.name}</span>
235
- </span>
236
- <span className="text-sm text-theme-text-primary tabular-nums text-right">{formatCost(ns.hourlyCost)}</span>
237
- <span className="text-sm text-theme-text-secondary tabular-nums text-right">~{formatCost(monthlyCost)}</span>
238
- <span className="text-right flex items-center justify-end gap-1">
239
- {hasEff ? (
240
- <>
241
- <span className={`text-xs font-medium tabular-nums ${efficiencyColor(eff)}`}>
242
- {eff.toFixed(0)}%
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 className="flex-1 h-2 rounded-full overflow-hidden bg-theme-hover flex" style={{ maxWidth: `${Math.max(barWidth, 3)}%` }}>
252
- <div className="h-full bg-blue-500/70" style={{ width: `${cpuPct}%` }} />
253
- <div className="h-full bg-purple-500/70" style={{ width: `${memPct}%` }} />
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/70" style={{ width: `${100 - cpuPct - memPct}%` }} />
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
- {formatCost(ns.cpuCost)} / {formatCost(ns.memoryCost)}
261
- {hasStorage && (ns.storageCost ?? 0) > 0 && ` / ${formatCost(ns.storageCost ?? 0)}`}
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
- <WorkloadRows namespace={ns.name} />
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({ namespace }: { namespace: string }) {
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 key={`${wl.kind}-${wl.name}`} wl={wl} maxCost={workloads[0]?.hourlyCost ?? 0} />
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({ wl, maxCost }: { wl: OpenCostWorkloadCost; maxCost: number }) {
304
- const monthlyCost = wl.hourlyCost * 730
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 eff = wl.efficiency ?? 0
308
- const hasEff = eff > 0
309
- const kindLabel = wl.kind === 'standalone' ? 'pod' : wl.kind
310
-
311
- return (
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">{kindLabel}</span>
315
- <span className="text-xs text-theme-text-secondary truncate">{wl.name}</span>
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">{formatCost(wl.hourlyCost)}</span>
321
- <span className="text-xs text-theme-text-tertiary tabular-nums text-right">~{formatCost(monthlyCost)}</span>
322
- <span className="text-right flex items-center justify-end gap-1">
323
- {hasEff ? (
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 className="flex-1 h-1.5 rounded-full overflow-hidden bg-theme-hover flex" style={{ maxWidth: `${Math.max(barWidth, 3)}%` }}>
336
- <div className="h-full bg-blue-500/50" style={{ width: `${cpuPct}%` }} />
337
- <div className="h-full bg-purple-500/50" style={{ width: `${100 - cpuPct}%` }} />
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
- {formatCost(wl.cpuCost)} / {formatCost(wl.memoryCost)}
559
+ {formatProjectedMonthlyCost(wl.cpuCost)} / {formatProjectedMonthlyCost(wl.memoryCost)}
342
560
  </span>
343
- </div>
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({ nodes }: { nodes: OpenCostNodeCost[] }) {
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)_90px_100px_140px] gap-2 px-4 py-2 border-b border-theme-border text-[11px] font-medium text-theme-text-tertiary uppercase tracking-wider">
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
- <span className="text-right cursor-help" title="Projected from current hourly rate — not historical spend">Monthly*</span>
372
- <span className="text-right">CPU / Memory</span>
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({ node }: { node: OpenCostNodeCost }) {
386
- const monthlyCost = node.hourlyCost * 730
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)_90px_100px_140px] gap-2 px-4 py-2.5">
390
- <span className="text-sm text-theme-text-primary truncate font-medium" title={node.name}>
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">{formatCost(node.hourlyCost)}</span>
398
- <span className="text-sm text-theme-text-secondary tabular-nums text-right">~{formatCost(monthlyCost)}</span>
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
- {formatCost(node.cpuCost)} / {formatCost(node.memoryCost)}
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-lg w-full mx-4 max-h-[80vh] overflow-y-auto">
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">Understanding Cost Data</h2>
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">Where do these costs come from?</h3>
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 cloud provider's
441
- pricing (how much each node costs per hour) with Kubernetes resource allocation data. This gives you
442
- a dollar value for each workload running on your cluster.
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">What does "hourly cost" mean?</h3>
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
- Each workload <strong>requests</strong> a certain amount of CPU and memory when it's deployed.
451
- These requests reserve capacity on a node that reserved capacity has a cost based on
452
- the node's cloud pricing, whether the workload actually uses it or not.
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
- The hourly cost shown here is based on what your workloads have <strong>reserved</strong> (requested),
456
- not what they're actually consuming. Monthly estimates simply multiply the current hourly rate by 730 hours.
457
- </p>
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">How fresh is this data?</h3>
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, efficiency, and breakdowns are <strong>snapshots based on the last 1 hour</strong> of data.
493
- They update automatically every minute. The trend chart is the only historical view — it shows how
494
- total cost has changed over the selected time range (6 hours, 24 hours, or 7 days).
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 reflected.
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">What are node costs?</h3>
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 type and
507
- cloud pricing. This is the total capacity cost — the namespace and workload breakdowns above
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
- }