Skip to content

Commit 68ee1ff

Browse files
committed
feat(frontend): adapt hardware cards to vertical space
The hardware cards only adapted to horizontal space, so on short viewports the square gauges and trend charts overflowed and broke the one-pager. Measure the available vertical space with a ResizeObserver and degrade gracefully: - New `useElementSize` hook (ResizeObserver, SSR/jsdom-safe). - New `HBar` component: a compact horizontal-bar gauge mirroring `ArcGauge`'s data API (single value+threshold, or stacked segments + legend). - When per-card height is tight, each hardware card drops its line chart and swaps its arc gauge for an `HBar` (Memory becomes a stacked segmented bar); value-only cards (Clock/Disk/Network) keep their readouts without the chart. The CPU core heatmap is dropped in this mode too, alongside the line charts. - When the dashboard content height is very short, the engine section also drops its per-metric trend charts so it stops crowding the hardware grid off-screen. Both decisions key off measured, content-independent heights (per-card height for the hardware swap, root height for the engine charts) so they can't feedback-loop. Verified across 1050/800/620/520px viewports via a throwaway Playwright preview. Adds HBar unit tests.
1 parent 3fba3d7 commit 68ee1ff

4 files changed

Lines changed: 343 additions & 74 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import { HBar } from '../components/gauges/HBar'
4+
import type { GaugeSegment } from '../components/gauges/ArcGauge'
5+
6+
describe('HBar', () => {
7+
it('renders a single value with label and unit', () => {
8+
render(<HBar value={42} label="GPU Util" unit="%" />)
9+
expect(screen.getByText('GPU Util')).toBeTruthy()
10+
expect(screen.getByText('42')).toBeTruthy()
11+
expect(screen.getByText('%')).toBeTruthy()
12+
})
13+
14+
it('fills the bar proportionally to value/max', () => {
15+
render(<HBar value={30} max={120} label="X" unit="W" />)
16+
const fill = screen.getByTestId('hbar-fill') as HTMLElement
17+
// 30/120 = 25%
18+
expect(fill.style.width).toBe('25%')
19+
})
20+
21+
it('clamps the fill width to [0, 100]%', () => {
22+
render(<HBar value={500} max={100} label="X" unit="%" />)
23+
expect((screen.getByTestId('hbar-fill') as HTMLElement).style.width).toBe('100%')
24+
})
25+
26+
it('prefers displayValue over value for the readout', () => {
27+
render(<HBar value={75} displayValue={150} label="GPU Power" unit="W" />)
28+
expect(screen.getByText('150')).toBeTruthy()
29+
})
30+
31+
it('renders stacked segments with a legend and no single-value fill', () => {
32+
// Mirrors how the Memory card calls it: an explicit `value` (used %) plus
33+
// segments for the stacked breakdown.
34+
const segments: GaugeSegment[] = [
35+
{ value: 25, total: 100, color: '#76B900', label: 'GPU: 25' },
36+
{ value: 25, total: 100, color: '#3B82F6', label: 'CPU: 25' },
37+
{ value: 50, total: 100, color: '#27272A', label: 'Free: 50' },
38+
]
39+
render(<HBar value={50} label="" unit="%" segments={segments} />)
40+
// No single-value fill is rendered in segment mode.
41+
expect(screen.queryByTestId('hbar-fill')).toBeNull()
42+
// Legend labels present.
43+
expect(screen.getByText('GPU: 25')).toBeTruthy()
44+
expect(screen.getByText('Free: 50')).toBeTruthy()
45+
// Readout uses the explicit value (used %), matching ArcGauge precedence.
46+
expect(screen.getByText('50')).toBeTruthy()
47+
})
48+
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import React from 'react'
2+
import { NVIDIA_THEME, thresholdColor } from '@/lib/theme'
3+
import type { GaugeSegment } from './ArcGauge'
4+
5+
interface HBarProps {
6+
value?: number
7+
max?: number
8+
label: string
9+
unit: string
10+
thresholds?: { warning: number; critical: number }
11+
/** Override the displayed number (e.g. show watts instead of percentage). */
12+
displayValue?: number
13+
/** When provided, renders a stacked multi-segment bar with a legend. */
14+
segments?: GaugeSegment[]
15+
}
16+
17+
/**
18+
* Compact horizontal-bar alternative to {@link ArcGauge}, used by the hardware
19+
* cards when vertical space is too tight for a square gauge. Accepts the same
20+
* data shape (single value+threshold, or stacked segments) so a card can swap
21+
* between the two without reshaping its props.
22+
*/
23+
export const HBar = React.memo(function HBar({
24+
value,
25+
max = 100,
26+
label,
27+
unit,
28+
thresholds,
29+
displayValue,
30+
segments,
31+
}: HBarProps) {
32+
const segs = segments?.filter((s) => s.value > 0 && s.total > 0) ?? []
33+
34+
const centerValue = (() => {
35+
if (displayValue !== undefined) return Math.round(displayValue)
36+
if (value !== undefined) return Math.round(value)
37+
if (segs.length > 0) {
38+
const total = segs[0].total
39+
if (total === 0) return 0
40+
const used = segs.filter((s) => s.label !== 'Free').reduce((sum, s) => sum + s.value, 0)
41+
return Math.round((used / total) * 100)
42+
}
43+
return 0
44+
})()
45+
46+
return (
47+
<div className="flex flex-col gap-0.5 lg:gap-1 w-full min-w-0">
48+
<div className="flex items-baseline gap-2 min-w-0">
49+
{label && (
50+
<span className="text-[9px] lg:text-[10px] text-zinc-400 uppercase tracking-wider truncate min-w-0">
51+
{label}
52+
</span>
53+
)}
54+
<span className="ml-auto shrink-0 font-mono font-bold tabular-nums text-xs lg:text-sm 2xl:text-base text-zinc-100 leading-none">
55+
{centerValue}
56+
<span className="ml-0.5 text-[9px] lg:text-[10px] text-zinc-500">{unit}</span>
57+
</span>
58+
</div>
59+
<div className="flex h-1.5 lg:h-2 w-full rounded-full overflow-hidden bg-zinc-700/40">
60+
{segs.length > 0 ? (
61+
segs.map((seg, i) => (
62+
<div
63+
key={i}
64+
className="h-full transition-all duration-500"
65+
style={{ width: `${Math.min(100, (seg.value / seg.total) * 100)}%`, backgroundColor: seg.color }}
66+
/>
67+
))
68+
) : (
69+
(() => {
70+
const v = value ?? 0
71+
const percent = Math.min(Math.max(v / max, 0), 1) * 100
72+
const color = thresholds
73+
? thresholdColor(v, thresholds.warning, thresholds.critical)
74+
: NVIDIA_THEME.accent
75+
return (
76+
<div
77+
className="h-full transition-all duration-500"
78+
style={{ width: `${percent}%`, backgroundColor: color }}
79+
data-testid="hbar-fill"
80+
/>
81+
)
82+
})()
83+
)}
84+
</div>
85+
{segs.length > 0 && (
86+
<div className="flex flex-wrap gap-x-1.5 gap-y-0.5">
87+
{segs.map((seg, i) => (
88+
<div key={i} className="flex items-center gap-0.5 min-w-0">
89+
<span className="inline-block w-1 h-1 rounded-full shrink-0" style={{ backgroundColor: seg.color }} />
90+
<span className="text-[8px] lg:text-[9px] text-zinc-300 truncate">{seg.label}</span>
91+
</div>
92+
))}
93+
</div>
94+
)}
95+
</div>
96+
)
97+
})

0 commit comments

Comments
 (0)