@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
@@ -0,0 +1,237 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import { LineChart } from 'lucide-react'
3
+ import { usePromQLRange, usePrometheusStatus, useAutoPromConnect, type PrometheusSeries } from '../../api/client'
4
+
5
+ /**
6
+ * HPACharts — replicas-over-time chart for an HPA.
7
+ *
8
+ * Sources from KSM `kube_horizontalpodautoscaler_status_{current,desired}_replicas`.
9
+ * Hidden silently when Prom isn't connected or KSM isn't reporting the series.
10
+ *
11
+ * Only the replicas series is plotted — KSM doesn't expose the observed metric
12
+ * the HPA target compares against, so an "observed vs target" chart would need
13
+ * cAdvisor derivation.
14
+ */
15
+ export function HPACharts({ data }: { data: any }) {
16
+ // HPA detail can be the first Prometheus-backed surface a user opens; without
17
+ // this, the chart silently stays empty until they open a workload metrics tab.
18
+ useAutoPromConnect()
19
+ const { data: status } = usePrometheusStatus()
20
+ const isConnected = status?.connected === true
21
+
22
+ const namespace = data?.metadata?.namespace ?? ''
23
+ const name = data?.metadata?.name ?? ''
24
+ const spec = data?.spec ?? {}
25
+ const min = spec.minReplicas ?? 1
26
+ const max = spec.maxReplicas
27
+
28
+ const currentQuery = useMemo(
29
+ () => `kube_horizontalpodautoscaler_status_current_replicas{namespace="${escapeLabel(namespace)}",horizontalpodautoscaler="${escapeLabel(name)}"}`,
30
+ [namespace, name],
31
+ )
32
+ const desiredQuery = useMemo(
33
+ () => `kube_horizontalpodautoscaler_status_desired_replicas{namespace="${escapeLabel(namespace)}",horizontalpodautoscaler="${escapeLabel(name)}"}`,
34
+ [namespace, name],
35
+ )
36
+
37
+ const enabled = isConnected && Boolean(namespace && name)
38
+ const { data: currentRes, error: currentErr } = usePromQLRange(currentQuery, '1h', enabled)
39
+ const { data: desiredRes, error: desiredErr } = usePromQLRange(desiredQuery, '1h', enabled)
40
+
41
+ const replicasPoints = useMemo(() => combineSeries({
42
+ current: currentRes?.series,
43
+ desired: desiredRes?.series,
44
+ }), [currentRes, desiredRes])
45
+
46
+ // Surface Prom-side failures in the console so an operator debugging a
47
+ // missing HPA chart has a breadcrumb; the chart still hides silently when
48
+ // KSM isn't reporting (the common no-data case). Effect-gated so we log
49
+ // once per error change, not on every re-render.
50
+ useEffect(() => {
51
+ if (currentErr || desiredErr) {
52
+ console.warn('[HPACharts] PromQL query failed', { currentErr, desiredErr })
53
+ }
54
+ }, [currentErr, desiredErr])
55
+
56
+ if (!isConnected) return null
57
+ if (!replicasPoints) return null
58
+
59
+ return (
60
+ <section className="mt-4 rounded-lg border border-theme-border bg-theme-surface/30 p-3">
61
+ <div className="flex items-center gap-2 mb-3 text-sm font-medium text-theme-text-secondary">
62
+ <LineChart className="w-4 h-4 text-theme-text-tertiary" />
63
+ Activity (last 1h)
64
+ </div>
65
+
66
+ <DualLineChart
67
+ title="Replicas"
68
+ height={120}
69
+ primary={{ label: 'current', points: replicasPoints.current, color: '#3b82f6' }}
70
+ secondary={{ label: 'desired', points: replicasPoints.desired, color: '#a855f7', dashed: true }}
71
+ referenceLines={[
72
+ { value: min, label: `min ${min}`, color: '#94a3b8' },
73
+ ...(max != null ? [{ value: max, label: `max ${max}`, color: '#94a3b8' }] : []),
74
+ ]}
75
+ formatY={(v) => v.toFixed(0)}
76
+ />
77
+ </section>
78
+ )
79
+ }
80
+
81
+ // ============================================================================
82
+ // Internals
83
+ // ============================================================================
84
+
85
+ interface FlatPoint { timestamp: number; value: number }
86
+
87
+ function extractFirstSeries(series: PrometheusSeries[]): FlatPoint[] | null {
88
+ for (const s of series) {
89
+ // Drop gap points (null/undefined value) so the line never plots NaN or a
90
+ // false 0. Replica-count series realistically never gap, so the survivors
91
+ // are joined into one continuous line (this bridges a gap, if one occurred —
92
+ // unlike AreaChart, which breaks the path)
93
+ const finite = s.dataPoints.filter((dp): dp is FlatPoint => dp.value != null)
94
+ if (finite.length > 0) {
95
+ return finite.map(dp => ({ timestamp: dp.timestamp, value: dp.value }))
96
+ }
97
+ }
98
+ return null
99
+ }
100
+
101
+ function combineSeries(args: { current?: PrometheusSeries[]; desired?: PrometheusSeries[] }): {
102
+ current: FlatPoint[]
103
+ desired: FlatPoint[]
104
+ } | null {
105
+ const current = args.current ? extractFirstSeries(args.current) : null
106
+ const desired = args.desired ? extractFirstSeries(args.desired) : null
107
+ if (!current && !desired) return null
108
+ return {
109
+ current: current ?? [],
110
+ desired: desired ?? [],
111
+ }
112
+ }
113
+
114
+ function escapeLabel(s: string): string {
115
+ return s.replace(/[\\"]/g, '\\$&')
116
+ }
117
+
118
+ // ============================================================================
119
+ // DualLineChart — minimal two-line chart for HPA-style time series.
120
+ // Deliberately separate from PrometheusCharts.AreaChart: the chart shapes are
121
+ // different (line not area, discrete integer Y axis for replicas), and
122
+ // reusing the area chart would require adding more knobs to it.
123
+ // ============================================================================
124
+
125
+ interface LineSpec {
126
+ label: string
127
+ points: FlatPoint[]
128
+ color: string
129
+ dashed?: boolean
130
+ }
131
+
132
+ interface RefLine { value: number; label: string; color: string }
133
+
134
+ function DualLineChart({ title, height, primary, secondary, referenceLines, formatY }: {
135
+ title: string
136
+ height: number
137
+ primary: LineSpec
138
+ secondary?: LineSpec
139
+ referenceLines?: RefLine[]
140
+ formatY: (v: number) => string
141
+ }) {
142
+ const allPoints = [...primary.points, ...(secondary?.points ?? [])]
143
+ if (allPoints.length === 0) {
144
+ return (
145
+ <div className="text-xs text-theme-text-tertiary">{title} — no data</div>
146
+ )
147
+ }
148
+
149
+ const minTs = Math.min(...allPoints.map(p => p.timestamp))
150
+ const maxTs = Math.max(...allPoints.map(p => p.timestamp))
151
+ const tsSpan = Math.max(maxTs - minTs, 60)
152
+
153
+ let maxV = Math.max(...allPoints.map(p => p.value), 1)
154
+ if (referenceLines) {
155
+ for (const rl of referenceLines) maxV = Math.max(maxV, rl.value)
156
+ }
157
+ // Add 10% headroom so the top line isn't flush with the top edge.
158
+ maxV = maxV * 1.1
159
+
160
+ const width = 600
161
+ const marginL = 36
162
+ const marginR = 16
163
+ const marginT = 4
164
+ const marginB = 18
165
+ const plotW = width - marginL - marginR
166
+ const plotH = height - marginT - marginB
167
+
168
+ const toX = (ts: number) => marginL + ((ts - minTs) / tsSpan) * plotW
169
+ const toY = (v: number) => marginT + plotH - (v / maxV) * plotH
170
+
171
+ const drawLine = (spec: LineSpec) => {
172
+ if (spec.points.length === 0) return null
173
+ const d = spec.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p.timestamp).toFixed(1)},${toY(p.value).toFixed(1)}`).join(' ')
174
+ return (
175
+ <path
176
+ d={d}
177
+ fill="none"
178
+ stroke={spec.color}
179
+ strokeWidth="1.75"
180
+ strokeLinejoin="round"
181
+ strokeDasharray={spec.dashed ? '4 3' : undefined}
182
+ />
183
+ )
184
+ }
185
+
186
+ return (
187
+ <div>
188
+ <div className="flex items-center justify-between mb-1.5">
189
+ <span className="text-xs text-theme-text-secondary">{title}</span>
190
+ <div className="flex items-center gap-3">
191
+ <Legend color={primary.color} label={primary.label} />
192
+ {secondary && <Legend color={secondary.color} label={secondary.label} dashed />}
193
+ </div>
194
+ </div>
195
+ <svg viewBox={`0 0 ${width} ${height}`} className="w-full" preserveAspectRatio="none">
196
+ {/* Y ticks */}
197
+ {[0, 0.5, 1].map(frac => {
198
+ const v = maxV * frac
199
+ const y = toY(v)
200
+ return (
201
+ <g key={frac}>
202
+ <line x1={marginL} y1={y} x2={width - marginR} y2={y} stroke="currentColor" className="text-theme-border/30" strokeWidth="1" />
203
+ <text x={marginL - 4} y={y + 3} textAnchor="end" fontSize="9" fontFamily="ui-monospace, monospace" className="fill-theme-text-tertiary">
204
+ {formatY(v)}
205
+ </text>
206
+ </g>
207
+ )
208
+ })}
209
+ {/* Reference lines */}
210
+ {referenceLines?.map((rl, i) => {
211
+ const y = toY(rl.value)
212
+ return (
213
+ <g key={`rl-${i}`}>
214
+ <line x1={marginL} y1={y} x2={width - marginR} y2={y} stroke={rl.color} strokeWidth="1" strokeDasharray="3 3" opacity="0.6" />
215
+ <text x={width - marginR - 4} y={y - 2} textAnchor="end" fontSize="9" fontFamily="ui-monospace, monospace" fill={rl.color} opacity="0.85">
216
+ {rl.label}
217
+ </text>
218
+ </g>
219
+ )
220
+ })}
221
+ {drawLine(primary)}
222
+ {secondary && drawLine(secondary)}
223
+ </svg>
224
+ </div>
225
+ )
226
+ }
227
+
228
+ function Legend({ color, label, dashed }: { color: string; label: string; dashed?: boolean }) {
229
+ return (
230
+ <span className="flex items-center gap-1 text-[11px] text-theme-text-tertiary">
231
+ <svg width="14" height="6" aria-hidden>
232
+ <line x1="0" y1="3" x2="14" y2="3" stroke={color} strokeWidth="1.75" strokeDasharray={dashed ? '3 2' : undefined} />
233
+ </svg>
234
+ {label}
235
+ </span>
236
+ )
237
+ }
@@ -0,0 +1,59 @@
1
+ import { formatMemoryBytes } from '@skyhook-io/k8s-ui/utils/format'
2
+ import { useAutoPromConnect, usePrometheusPVCUsage, usePrometheusStatus } from '../../api/client'
3
+
4
+ /**
5
+ * PVCUsageBar — single-line capacity gauge derived from kubelet_volume_stats_*.
6
+ *
7
+ * Hidden silently when:
8
+ * - Prometheus isn't connected
9
+ * - The CSI driver doesn't implement NodeGetVolumeStats
10
+ * - Prometheus isn't scraping kubelet endpoints (notably GMP default config)
11
+ *
12
+ * Operators get nothing rather than a "no data" message that'd look like Radar
13
+ * is broken — the absence is information enough.
14
+ */
15
+ export function PVCUsageBar({ namespace, name }: { namespace: string; name: string }) {
16
+ // PVC detail can be the first Prometheus-backed surface a user opens; without
17
+ // this, the gauge silently stays hidden until they open a workload metrics tab.
18
+ useAutoPromConnect()
19
+ const { data: status } = usePrometheusStatus()
20
+ const isConnected = status?.connected === true
21
+ const { data: usage } = usePrometheusPVCUsage(namespace, name, isConnected)
22
+
23
+ if (!usage || !usage.hasData) return null
24
+
25
+ const pct = Math.max(0, Math.min(1, usage.ratio))
26
+ const usedLabel = formatMemoryBytes(usage.used)
27
+ const capLabel = formatMemoryBytes(usage.capacity)
28
+ const pctLabel = `${(pct * 100).toFixed(0)}%`
29
+
30
+ // Tone: green well under, amber > 75%, red > 90%. PVCs fill silently — the
31
+ // top tone is justified because the consequence (write failures) is severe.
32
+ const tone = pct >= 0.9 ? 'critical' : pct >= 0.75 ? 'warning' : 'ok'
33
+ const barColor =
34
+ tone === 'critical' ? 'bg-red-500' :
35
+ tone === 'warning' ? 'bg-amber-500' :
36
+ 'bg-emerald-500'
37
+ // Light/dark-paired text tones — `text-red-400` alone washes out in light
38
+ // mode (Tailwind's 400 stop is calibrated for dark backgrounds).
39
+ const textColor =
40
+ tone === 'critical' ? 'text-red-700 dark:text-red-400' :
41
+ tone === 'warning' ? 'text-amber-700 dark:text-amber-400' :
42
+ 'text-theme-text-secondary'
43
+
44
+ return (
45
+ <section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3">
46
+ <div className="flex items-center justify-between mb-2">
47
+ <span className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">Usage</span>
48
+ <span className={`text-sm font-semibold tabular-nums ${textColor}`}>
49
+ {usedLabel} <span className="text-theme-text-quaternary font-normal">/</span> {capLabel}
50
+ <span className="ml-2 text-theme-text-tertiary text-xs font-normal">({pctLabel})</span>
51
+ </span>
52
+ </div>
53
+ <div className="h-2 rounded-full bg-theme-elevated overflow-hidden">
54
+ <div className={`h-full ${barColor} transition-all`} style={{ width: `${pct * 100}%` }} />
55
+ </div>
56
+ </section>
57
+ )
58
+ }
59
+