@skyhook-io/radar-app 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +81 -18
  3. package/src/api/client.ts +165 -11
  4. package/src/api/rbac.ts +57 -0
  5. package/src/components/compare/CompareViewRoute.tsx +116 -0
  6. package/src/components/compare/useCompareCandidates.ts +27 -0
  7. package/src/components/compare/useCompareLauncher.tsx +76 -0
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/gitops/GitOpsView.tsx +1 -1
  10. package/src/components/helm/InstallWizard.tsx +5 -5
  11. package/src/components/helm/ValuesViewer.tsx +3 -39
  12. package/src/components/home/HomeView.tsx +18 -2
  13. package/src/components/resource/HPACharts.tsx +232 -0
  14. package/src/components/resource/PVCUsageBar.tsx +59 -0
  15. package/src/components/resource/PrometheusCharts.tsx +151 -434
  16. package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
  17. package/src/components/resource/RestartChart.tsx +124 -0
  18. package/src/components/resource/RightsizingStrip.tsx +167 -0
  19. package/src/components/resources/CompositeRenderer.tsx +101 -0
  20. package/src/components/resources/renderers/HPARenderer.tsx +17 -1
  21. package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
  22. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  23. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  24. package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
  25. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  26. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
  28. package/src/components/resources/renderers/index.ts +1 -0
  29. package/src/components/settings/MyPermissionsDialog.tsx +231 -0
  30. package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
  31. package/src/components/workload/WorkloadView.tsx +107 -3
  32. package/src/context/NavCustomization.tsx +13 -0
@@ -0,0 +1,76 @@
1
+ import { useCallback, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { CompareResourcePicker, refToParam, type CompareResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { useCompareCandidates } from './useCompareCandidates'
5
+ import { useNavCustomization } from '../../context/NavCustomization'
6
+
7
+ interface UseCompareLauncherArgs {
8
+ /** API plural kind (e.g. "deployments") — must match the route segment used by `/api/resources/{kind}`. */
9
+ kind: string
10
+ namespace: string
11
+ name: string
12
+ /** API group for the resource — required for CRDs that collide with core kinds. */
13
+ group?: string
14
+ }
15
+
16
+ interface CompareLauncher {
17
+ /** Wire this to ResourceActionsBar's `onCompareTo` prop. */
18
+ onCompareTo: () => void
19
+ /**
20
+ * Wire this to ResourceActionsBar's `onCompareAcrossClusters` prop. Undefined
21
+ * when the host (NavCustomization.crossClusterCompareHref) hasn't opted in
22
+ * — keeps the standalone Radar experience identical.
23
+ */
24
+ onCompareAcrossClusters?: () => void
25
+ /** Render this anywhere in the same tree to surface the picker dialog. */
26
+ picker: React.ReactNode
27
+ }
28
+
29
+ export function useCompareLauncher({ kind, namespace, name, group }: UseCompareLauncherArgs): CompareLauncher {
30
+ const navigate = useNavigate()
31
+ const [open, setOpen] = useState(false)
32
+ const kindLower = kind.toLowerCase()
33
+ const { candidates, isPending, error } = useCompareCandidates(kindLower, group, open)
34
+ const { crossClusterCompareHref } = useNavCustomization()
35
+
36
+ const onCompareTo = useCallback(() => setOpen(true), [])
37
+
38
+ const onCompareAcrossClusters = useCallback(() => {
39
+ if (!crossClusterCompareHref) return
40
+ const href = crossClusterCompareHref({ kind: kindLower, namespace, name, group })
41
+ window.location.assign(href)
42
+ }, [crossClusterCompareHref, kindLower, namespace, name, group])
43
+
44
+ const handlePick = useCallback(
45
+ (picked: CompareResourceRef) => {
46
+ setOpen(false)
47
+ const params = new URLSearchParams()
48
+ params.set('kind', kindLower)
49
+ if (group) params.set('apiGroup', group)
50
+ params.set('a', refToParam({ namespace, name }))
51
+ params.set('b', refToParam({ namespace: picked.namespace, name: picked.name }))
52
+ navigate({ pathname: '/compare', search: params.toString() })
53
+ },
54
+ [navigate, kindLower, group, namespace, name],
55
+ )
56
+
57
+ const source: CompareResourceRef = { kind: kindLower, namespace, name, group }
58
+
59
+ const picker = (
60
+ <CompareResourcePicker
61
+ open={open}
62
+ onClose={() => setOpen(false)}
63
+ source={source}
64
+ candidates={candidates}
65
+ loading={open && isPending}
66
+ error={open ? error : null}
67
+ onPick={handlePick}
68
+ />
69
+ )
70
+
71
+ return {
72
+ onCompareTo,
73
+ onCompareAcrossClusters: crossClusterCompareHref ? onCompareAcrossClusters : undefined,
74
+ picker,
75
+ }
76
+ }
@@ -417,7 +417,7 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
417
417
  return (
418
418
  <div className="fixed inset-0 z-50 flex items-center justify-center">
419
419
  <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">
420
+ <div className="relative dialog max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
421
421
  {/* Header */}
422
422
  <div className="flex items-center justify-between p-4 border-b border-theme-border sticky top-0 bg-theme-surface rounded-t-lg">
423
423
  <div className="flex items-center gap-2">
@@ -685,7 +685,7 @@ function extractHelmValues(kind: string, resource: any): HelmValuesData | null {
685
685
 
686
686
  function safeStringifyYaml(value: unknown): string {
687
687
  try {
688
- return yaml.stringify(value)
688
+ return yaml.stringify(value, { lineWidth: 0 })
689
689
  } catch {
690
690
  return JSON.stringify(value, null, 2)
691
691
  }
@@ -106,11 +106,11 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
106
106
  }
107
107
  }
108
108
  if (baseValues && defaultValues) {
109
- setValuesYaml(yaml.stringify(deepMerge(baseValues, defaultValues)))
109
+ setValuesYaml(yaml.stringify(deepMerge(baseValues, defaultValues), { lineWidth: 0 }))
110
110
  } else if (defaultValues && !baseValues) {
111
- setValuesYaml(yaml.stringify(defaultValues))
111
+ setValuesYaml(yaml.stringify(defaultValues, { lineWidth: 0 }))
112
112
  } else if (baseValues) {
113
- setValuesYaml(yaml.stringify(baseValues))
113
+ setValuesYaml(yaml.stringify(baseValues, { lineWidth: 0 }))
114
114
  }
115
115
  }, [localChartDetail?.values, artifactHubDetail?.values, isLocal, defaultValues])
116
116
 
@@ -349,7 +349,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
349
349
  valuesYaml={valuesYaml}
350
350
  defaultValuesYaml={
351
351
  isLocal
352
- ? (localChartDetail?.values ? yaml.stringify(localChartDetail.values) : '')
352
+ ? (localChartDetail?.values ? yaml.stringify(localChartDetail.values, { lineWidth: 0 }) : '')
353
353
  : (artifactHubDetail?.values || '')
354
354
  }
355
355
  />
@@ -664,7 +664,7 @@ function ValuesStep({ valuesYaml, setValuesYaml, yamlError, setYamlError, chartD
664
664
  const ahDetail = chartDetail as ArtifactHubChartDetail | undefined
665
665
 
666
666
  const defaultValues = isLocal
667
- ? (localDetail?.values ? yaml.stringify(localDetail.values) : '')
667
+ ? (localDetail?.values ? yaml.stringify(localDetail.values, { lineWidth: 0 }) : '')
668
668
  : (ahDetail?.values || '')
669
669
 
670
670
  const hasDefaults = Boolean(defaultValues)
@@ -320,43 +320,7 @@ function ToggleButton({ showAll, onToggle, disabled }: { showAll: boolean; onTog
320
320
  )
321
321
  }
322
322
 
323
- // Simple JSON to YAML converter for display
324
- function jsonToYaml(obj: Record<string, unknown>, indent = 0): string {
325
- const spaces = ' '.repeat(indent)
326
- let result = ''
327
-
328
- for (const [key, value] of Object.entries(obj)) {
329
- if (value === null || value === undefined) {
330
- result += `${spaces}${key}: null\n`
331
- } else if (typeof value === 'object' && !Array.isArray(value)) {
332
- result += `${spaces}${key}:\n`
333
- result += jsonToYaml(value as Record<string, unknown>, indent + 1)
334
- } else if (Array.isArray(value)) {
335
- result += `${spaces}${key}:\n`
336
- for (const item of value) {
337
- if (typeof item === 'object' && item !== null) {
338
- result += `${spaces}- \n`
339
- const itemYaml = jsonToYaml(item as Record<string, unknown>, indent + 2)
340
- result += itemYaml
341
- } else {
342
- result += `${spaces}- ${formatValue(item)}\n`
343
- }
344
- }
345
- } else {
346
- result += `${spaces}${key}: ${formatValue(value)}\n`
347
- }
348
- }
349
-
350
- return result
351
- }
352
-
353
- function formatValue(value: unknown): string {
354
- if (typeof value === 'string') {
355
- // Quote strings that contain special characters
356
- if (value.includes(':') || value.includes('#') || value.includes('\n') || value.startsWith(' ') || value.endsWith(' ')) {
357
- return `"${value.replace(/"/g, '\\"')}"`
358
- }
359
- return value
360
- }
361
- return String(value)
323
+ function jsonToYaml(obj: Record<string, unknown>): string {
324
+ if (!obj || Object.keys(obj).length === 0) return ''
325
+ return yaml.stringify(obj, { lineWidth: 0 })
362
326
  }
@@ -1,3 +1,4 @@
1
+ import { useMemo } from 'react'
1
2
  import { useDashboard, useDashboardCRDs, useDashboardHelm } from '../../api/client'
2
3
  import type { DashboardResponse } from '../../api/client'
3
4
  import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
@@ -25,6 +26,21 @@ interface HomeViewProps {
25
26
 
26
27
  export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
27
28
  const { data, isLoading, error } = useDashboard(namespaces)
29
+
30
+ // SSE is cluster-wide on small/medium clusters; the picker only narrows the
31
+ // dashboard summary, so re-apply the filter here or the legend disagrees.
32
+ const scopedTopology = useMemo<Topology | null>(() => {
33
+ if (!topology) return null
34
+ if (namespaces.length === 0) return topology
35
+ const nsSet = new Set(namespaces)
36
+ const nodes = topology.nodes.filter(n => {
37
+ const ns = n.data.namespace as string | undefined
38
+ return !ns || nsSet.has(ns)
39
+ })
40
+ const nodeIds = new Set(nodes.map(n => n.id))
41
+ const edges = topology.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
42
+ return { nodes, edges }
43
+ }, [topology, namespaces])
28
44
  // CRDs and Helm load lazily after main dashboard to keep initial load fast
29
45
  const { data: crdsData } = useDashboardCRDs(namespaces)
30
46
  const { data: helmData } = useDashboardHelm(namespaces)
@@ -100,7 +116,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
100
116
  {/* Primary cards — 2-col grid */}
101
117
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
102
118
  <TopologyPreview
103
- topology={topology}
119
+ topology={scopedTopology}
104
120
  summary={data.topologySummary}
105
121
  onNavigate={() => onNavigateToView('topology')}
106
122
  />
@@ -110,7 +126,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
110
126
  />
111
127
  <ActivitySummary
112
128
  namespaces={namespaces}
113
- topology={topology}
129
+ topology={scopedTopology}
114
130
  onNavigate={() => onNavigateToView('timeline')}
115
131
  />
116
132
  <TrafficSummary
@@ -0,0 +1,232 @@
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
+ if (s.dataPoints.length > 0) {
90
+ return s.dataPoints.map(dp => ({ timestamp: dp.timestamp, value: dp.value }))
91
+ }
92
+ }
93
+ return null
94
+ }
95
+
96
+ function combineSeries(args: { current?: PrometheusSeries[]; desired?: PrometheusSeries[] }): {
97
+ current: FlatPoint[]
98
+ desired: FlatPoint[]
99
+ } | null {
100
+ const current = args.current ? extractFirstSeries(args.current) : null
101
+ const desired = args.desired ? extractFirstSeries(args.desired) : null
102
+ if (!current && !desired) return null
103
+ return {
104
+ current: current ?? [],
105
+ desired: desired ?? [],
106
+ }
107
+ }
108
+
109
+ function escapeLabel(s: string): string {
110
+ return s.replace(/[\\"]/g, '\\$&')
111
+ }
112
+
113
+ // ============================================================================
114
+ // DualLineChart — minimal two-line chart for HPA-style time series.
115
+ // Deliberately separate from PrometheusCharts.AreaChart: the chart shapes are
116
+ // different (line not area, discrete integer Y axis for replicas), and
117
+ // reusing the area chart would require adding more knobs to it.
118
+ // ============================================================================
119
+
120
+ interface LineSpec {
121
+ label: string
122
+ points: FlatPoint[]
123
+ color: string
124
+ dashed?: boolean
125
+ }
126
+
127
+ interface RefLine { value: number; label: string; color: string }
128
+
129
+ function DualLineChart({ title, height, primary, secondary, referenceLines, formatY }: {
130
+ title: string
131
+ height: number
132
+ primary: LineSpec
133
+ secondary?: LineSpec
134
+ referenceLines?: RefLine[]
135
+ formatY: (v: number) => string
136
+ }) {
137
+ const allPoints = [...primary.points, ...(secondary?.points ?? [])]
138
+ if (allPoints.length === 0) {
139
+ return (
140
+ <div className="text-xs text-theme-text-tertiary">{title} — no data</div>
141
+ )
142
+ }
143
+
144
+ const minTs = Math.min(...allPoints.map(p => p.timestamp))
145
+ const maxTs = Math.max(...allPoints.map(p => p.timestamp))
146
+ const tsSpan = Math.max(maxTs - minTs, 60)
147
+
148
+ let maxV = Math.max(...allPoints.map(p => p.value), 1)
149
+ if (referenceLines) {
150
+ for (const rl of referenceLines) maxV = Math.max(maxV, rl.value)
151
+ }
152
+ // Add 10% headroom so the top line isn't flush with the top edge.
153
+ maxV = maxV * 1.1
154
+
155
+ const width = 600
156
+ const marginL = 36
157
+ const marginR = 16
158
+ const marginT = 4
159
+ const marginB = 18
160
+ const plotW = width - marginL - marginR
161
+ const plotH = height - marginT - marginB
162
+
163
+ const toX = (ts: number) => marginL + ((ts - minTs) / tsSpan) * plotW
164
+ const toY = (v: number) => marginT + plotH - (v / maxV) * plotH
165
+
166
+ const drawLine = (spec: LineSpec) => {
167
+ if (spec.points.length === 0) return null
168
+ const d = spec.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p.timestamp).toFixed(1)},${toY(p.value).toFixed(1)}`).join(' ')
169
+ return (
170
+ <path
171
+ d={d}
172
+ fill="none"
173
+ stroke={spec.color}
174
+ strokeWidth="1.75"
175
+ strokeLinejoin="round"
176
+ strokeDasharray={spec.dashed ? '4 3' : undefined}
177
+ />
178
+ )
179
+ }
180
+
181
+ return (
182
+ <div>
183
+ <div className="flex items-center justify-between mb-1.5">
184
+ <span className="text-xs text-theme-text-secondary">{title}</span>
185
+ <div className="flex items-center gap-3">
186
+ <Legend color={primary.color} label={primary.label} />
187
+ {secondary && <Legend color={secondary.color} label={secondary.label} dashed />}
188
+ </div>
189
+ </div>
190
+ <svg viewBox={`0 0 ${width} ${height}`} className="w-full" preserveAspectRatio="none">
191
+ {/* Y ticks */}
192
+ {[0, 0.5, 1].map(frac => {
193
+ const v = maxV * frac
194
+ const y = toY(v)
195
+ return (
196
+ <g key={frac}>
197
+ <line x1={marginL} y1={y} x2={width - marginR} y2={y} stroke="currentColor" className="text-theme-border/30" strokeWidth="1" />
198
+ <text x={marginL - 4} y={y + 3} textAnchor="end" fontSize="9" fontFamily="ui-monospace, monospace" className="fill-theme-text-tertiary">
199
+ {formatY(v)}
200
+ </text>
201
+ </g>
202
+ )
203
+ })}
204
+ {/* Reference lines */}
205
+ {referenceLines?.map((rl, i) => {
206
+ const y = toY(rl.value)
207
+ return (
208
+ <g key={`rl-${i}`}>
209
+ <line x1={marginL} y1={y} x2={width - marginR} y2={y} stroke={rl.color} strokeWidth="1" strokeDasharray="3 3" opacity="0.6" />
210
+ <text x={width - marginR - 4} y={y - 2} textAnchor="end" fontSize="9" fontFamily="ui-monospace, monospace" fill={rl.color} opacity="0.85">
211
+ {rl.label}
212
+ </text>
213
+ </g>
214
+ )
215
+ })}
216
+ {drawLine(primary)}
217
+ {secondary && drawLine(secondary)}
218
+ </svg>
219
+ </div>
220
+ )
221
+ }
222
+
223
+ function Legend({ color, label, dashed }: { color: string; label: string; dashed?: boolean }) {
224
+ return (
225
+ <span className="flex items-center gap-1 text-[11px] text-theme-text-tertiary">
226
+ <svg width="14" height="6" aria-hidden>
227
+ <line x1="0" y1="3" x2="14" y2="3" stroke={color} strokeWidth="1.75" strokeDasharray={dashed ? '3 2' : undefined} />
228
+ </svg>
229
+ {label}
230
+ </span>
231
+ )
232
+ }
@@ -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
+