@flame0510/project-aether 1.7.0 → 1.8.0

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.
@@ -0,0 +1,226 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
4
+
5
+ export interface TimeSeriesPoint {
6
+ /** Seconds since the epoch — the start of the bucket. */
7
+ ts: number;
8
+ /** The plotted value (an average over the bucket); null leaves a gap. */
9
+ value: number | null;
10
+ /** The bucket's peak, drawn as a light wash under the line and shown in the tooltip. */
11
+ peak?: number | null;
12
+ }
13
+
14
+ interface TimeSeriesChartProps {
15
+ /** What is plotted, for screen readers and the table caption. */
16
+ label: string;
17
+ points: TimeSeriesPoint[];
18
+ /** The x domain in seconds, so an empty stretch reads as "no data", not as a zoom. */
19
+ from: number;
20
+ to: number;
21
+ /** Seconds per point; two points further apart than 1.5 buckets are not joined. */
22
+ bucket: number;
23
+ yMax?: number;
24
+ unit?: string;
25
+ height?: number;
26
+ /** While a new range loads the previous render stays, dimmed. */
27
+ stale?: boolean;
28
+ formatTime: (ts: number) => string;
29
+ }
30
+
31
+ const PAD = { top: 12, right: 44, bottom: 22, left: 34 };
32
+
33
+ /**
34
+ * One series over time: a 2px line for the value, a 10% wash up to the peak, a hairline
35
+ * grid at 0 / 50 / 100 %, the latest value labelled at the line's end. Hover or focus
36
+ * shows a crosshair snapped to the nearest point with its value and peak; the same data
37
+ * is available as a table. Plain SVG measured to its container, so text is never scaled.
38
+ */
39
+ export function TimeSeriesChart({
40
+ label,
41
+ points,
42
+ from,
43
+ to,
44
+ bucket,
45
+ yMax = 100,
46
+ unit = '%',
47
+ height = 160,
48
+ stale = false,
49
+ formatTime,
50
+ }: TimeSeriesChartProps) {
51
+ const boxRef = useRef<HTMLDivElement>(null);
52
+ const clipId = `ts-clip-${useId().replace(/:/g, '')}`;
53
+ const [width, setWidth] = useState(0);
54
+ const [active, setActive] = useState<number | null>(null);
55
+
56
+ useEffect(() => {
57
+ const el = boxRef.current;
58
+ if (!el) return;
59
+ const ro = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
60
+ ro.observe(el);
61
+ return () => ro.disconnect();
62
+ }, []);
63
+
64
+ const plotW = Math.max(0, width - PAD.left - PAD.right);
65
+ const plotH = height - PAD.top - PAD.bottom;
66
+ const span = Math.max(1, to - from);
67
+ const x = (ts: number) => PAD.left + ((ts - from) / span) * plotW;
68
+ const y = (v: number) => PAD.top + plotH - (Math.max(0, Math.min(yMax, v)) / yMax) * plotH;
69
+
70
+ const valid = useMemo(() => points.filter((p) => p.value !== null), [points]);
71
+
72
+ // Runs of points close enough in time to be joined; a gap means no samples there.
73
+ const runs = useMemo(() => {
74
+ const out: TimeSeriesPoint[][] = [];
75
+ let run: TimeSeriesPoint[] = [];
76
+ for (const p of valid) {
77
+ const prev = run[run.length - 1];
78
+ if (prev && p.ts - prev.ts > bucket * 1.5) {
79
+ out.push(run);
80
+ run = [];
81
+ }
82
+ run.push(p);
83
+ }
84
+ if (run.length) out.push(run);
85
+ return out;
86
+ }, [valid, bucket]);
87
+
88
+ const linePath = (run: TimeSeriesPoint[]) =>
89
+ run.map((p, i) => `${i ? 'L' : 'M'}${x(p.ts).toFixed(1)},${y(p.value as number).toFixed(1)}`).join('');
90
+ const peakArea = (run: TimeSeriesPoint[]) => {
91
+ const top = run.map((p, i) => `${i ? 'L' : 'M'}${x(p.ts).toFixed(1)},${y(p.peak ?? (p.value as number)).toFixed(1)}`).join('');
92
+ const base = y(0).toFixed(1);
93
+ return `${top}L${x(run[run.length - 1].ts).toFixed(1)},${base}L${x(run[0].ts).toFixed(1)},${base}Z`;
94
+ };
95
+
96
+ const last = valid[valid.length - 1];
97
+ const shown = active !== null ? valid[active] : null;
98
+ const fmt = (v: number | null | undefined) => (v === null || v === undefined ? '—' : `${Math.round(v * 10) / 10}${unit}`);
99
+
100
+ function onPointerMove(e: PointerEvent<SVGRectElement>) {
101
+ if (!valid.length || plotW <= 0) return;
102
+ const rect = e.currentTarget.getBoundingClientRect();
103
+ const ts = from + ((e.clientX - rect.left) / rect.width) * span;
104
+ let best = 0;
105
+ for (let i = 1; i < valid.length; i++) {
106
+ if (Math.abs(valid[i].ts - ts) < Math.abs(valid[best].ts - ts)) best = i;
107
+ }
108
+ setActive(best);
109
+ }
110
+
111
+ function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
112
+ if (!valid.length) return;
113
+ if (e.key === 'ArrowLeft') setActive((a) => Math.max(0, (a ?? valid.length) - 1));
114
+ else if (e.key === 'ArrowRight') setActive((a) => Math.min(valid.length - 1, (a ?? -1) + 1));
115
+ else if (e.key === 'Escape') setActive(null);
116
+ else return;
117
+ e.preventDefault();
118
+ }
119
+
120
+ const ticks = [from, from + span / 2, to];
121
+ const tooltipLeft = shown ? Math.min(Math.max(x(shown.ts), 70), Math.max(70, width - 70)) : 0;
122
+
123
+ return (
124
+ <div className="ui-chart" style={{ opacity: stale ? 0.5 : 1 }}>
125
+ <div
126
+ ref={boxRef}
127
+ className="ui-chart__plot"
128
+ style={{ height }}
129
+ tabIndex={0}
130
+ role="img"
131
+ aria-label={last ? `${label}: latest ${fmt(last.value)}` : `${label}: no data`}
132
+ onKeyDown={onKeyDown}
133
+ onFocus={() => setActive((a) => a ?? (valid.length ? valid.length - 1 : null))}
134
+ onBlur={() => setActive(null)}
135
+ >
136
+ {width > 0 && (
137
+ <svg width={width} height={height} aria-hidden="true">
138
+ {[0, 50, 100].map((v) => (
139
+ <g key={v}>
140
+ <line x1={PAD.left} x2={PAD.left + plotW} y1={y((v / 100) * yMax)} y2={y((v / 100) * yMax)} className="ui-chart__grid" />
141
+ <text x={PAD.left - 6} y={y((v / 100) * yMax) + 3} textAnchor="end" className="ui-chart__tick">
142
+ {Math.round((v / 100) * yMax)}{unit}
143
+ </text>
144
+ </g>
145
+ ))}
146
+ {ticks.map((ts, i) => (
147
+ <text
148
+ key={i}
149
+ x={x(ts)}
150
+ y={height - 6}
151
+ textAnchor={i === 0 ? 'start' : i === 2 ? 'end' : 'middle'}
152
+ className="ui-chart__tick"
153
+ >
154
+ {formatTime(ts)}
155
+ </text>
156
+ ))}
157
+ <defs>
158
+ {/* Marks stay inside the plot, never over the axis labels. */}
159
+ <clipPath id={clipId}>
160
+ <rect x={PAD.left} y={0} width={plotW} height={PAD.top + plotH} />
161
+ </clipPath>
162
+ </defs>
163
+ <g clipPath={`url(#${clipId})`}>
164
+ {runs.map((run, i) => (
165
+ <g key={i}>
166
+ <path d={peakArea(run)} className="ui-chart__wash" />
167
+ {run.length > 1 && <path d={linePath(run)} className="ui-chart__line" />}
168
+ {run.length === 1 && <circle cx={x(run[0].ts)} cy={y(run[0].value as number)} r={2} className="ui-chart__dot" />}
169
+ </g>
170
+ ))}
171
+ </g>
172
+ {last && !shown && (
173
+ <g>
174
+ <circle cx={x(last.ts)} cy={y(last.value as number)} r={4} className="ui-chart__marker" />
175
+ <text x={x(last.ts) + 8} y={y(last.value as number) + 3} className="ui-chart__end-label">{fmt(last.value)}</text>
176
+ </g>
177
+ )}
178
+ {shown && (
179
+ <g>
180
+ <line x1={x(shown.ts)} x2={x(shown.ts)} y1={PAD.top} y2={PAD.top + plotH} className="ui-chart__crosshair" />
181
+ <circle cx={x(shown.ts)} cy={y(shown.value as number)} r={4} className="ui-chart__marker" />
182
+ </g>
183
+ )}
184
+ {!valid.length && (
185
+ <text x={PAD.left + plotW / 2} y={PAD.top + plotH / 2} textAnchor="middle" className="ui-chart__tick">No samples in this range</text>
186
+ )}
187
+ <rect
188
+ x={PAD.left}
189
+ y={PAD.top}
190
+ width={plotW}
191
+ height={plotH}
192
+ fill="transparent"
193
+ onPointerMove={onPointerMove}
194
+ onPointerLeave={() => setActive(null)}
195
+ />
196
+ </svg>
197
+ )}
198
+ {shown && (
199
+ <div className="ui-chart__tooltip" style={{ left: tooltipLeft }}>
200
+ <strong>{fmt(shown.value)}</strong>
201
+ {shown.peak !== undefined && <span className="ui-muted"> · peak {fmt(shown.peak)}</span>}
202
+ <div className="ui-muted">{formatTime(shown.ts)}</div>
203
+ </div>
204
+ )}
205
+ </div>
206
+ <details className="ui-chart__table">
207
+ <summary>Table</summary>
208
+ <table>
209
+ <caption>{label}</caption>
210
+ <thead>
211
+ <tr><th scope="col">Time</th><th scope="col">Value</th>{valid.some((p) => p.peak !== undefined) && <th scope="col">Peak</th>}</tr>
212
+ </thead>
213
+ <tbody>
214
+ {valid.map((p) => (
215
+ <tr key={p.ts}>
216
+ <td>{formatTime(p.ts)}</td>
217
+ <td>{fmt(p.value)}</td>
218
+ {p.peak !== undefined && <td>{fmt(p.peak)}</td>}
219
+ </tr>
220
+ ))}
221
+ </tbody>
222
+ </table>
223
+ </details>
224
+ </div>
225
+ );
226
+ }
@@ -19,3 +19,5 @@ export * from './Tabs';
19
19
  export * from './ItemList';
20
20
  export * from './TemplateOption';
21
21
  export { default as ModalityIcons, parseModality, type ParsedModality } from './ModalityIcons';
22
+ export * from './Meter';
23
+ export * from './TimeSeriesChart';
package/app/globals.css CHANGED
@@ -636,8 +636,49 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
636
636
  .ui-pill.ui-tone--info { border-color: rgba(99,102,241,0.28); color: #818cf8; background: rgba(99,102,241,0.12); }
637
637
  .ui-tone--info { border-color: rgba(99,102,241,0.28); }
638
638
 
639
+ /* Meter: a 0-100 % fill whose colour carries the level; the level is also written out. */
640
+ .ui-meter { display: flex; align-items: center; gap: 8px; margin-top: 12px; min-height: 15px; }
641
+ .ui-meter__track { flex: 1; height: 6px; background: var(--bg3); border-radius: 0; overflow: hidden; }
642
+ .ui-meter__fill { height: 100%; transition: width 0.3s; }
643
+ .ui-meter__fill--ok { background: var(--violet); }
644
+ .ui-meter__fill--warning { background: var(--yellow); }
645
+ .ui-meter__fill--critical { background: var(--red); }
646
+ .ui-meter__level { font-size: 10px; text-transform: uppercase; font-weight: 700; letter-spacing: 0.08em; }
647
+ .ui-meter__level--warning { color: var(--yellow); }
648
+ .ui-meter__level--critical { color: var(--red); }
649
+
650
+ /* TimeSeriesChart: one series, 2px line, 10% peak wash, hairline grid, text in text tokens. */
651
+ .ui-chart { transition: opacity 0.2s; }
652
+ .ui-chart__plot { position: relative; width: 100%; outline: none; }
653
+ .ui-chart__plot:focus-visible { outline: 1px solid var(--violet-dim); outline-offset: -1px; }
654
+ .ui-chart__grid { stroke: var(--border); stroke-width: 1; }
655
+ .ui-chart__tick { fill: var(--text-dim); font-size: 10px; font-family: var(--font-mono-stack); font-variant-numeric: tabular-nums; }
656
+ .ui-chart__line { fill: none; stroke: var(--violet); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
657
+ .ui-chart__wash { fill: var(--violet); fill-opacity: 0.1; stroke: none; }
658
+ .ui-chart__dot { fill: var(--violet); }
659
+ .ui-chart__marker { fill: var(--violet); stroke: var(--bg2); stroke-width: 2; }
660
+ .ui-chart__end-label { fill: var(--text); font-size: 11px; font-family: var(--font-mono-stack); }
661
+ .ui-chart__crosshair { stroke: var(--text-dim); stroke-width: 1; }
662
+ .ui-chart__tooltip { position: absolute; top: 0; transform: translateX(-50%); pointer-events: none; background: var(--bg); border: 1px solid var(--border); padding: 6px 8px; font-size: 11px; white-space: nowrap; }
663
+ .ui-chart__table { margin-top: 6px; font-size: 11px; }
664
+ .ui-chart__table summary { cursor: pointer; color: var(--text-dim); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; }
665
+ .ui-chart__table table { width: 100%; border-collapse: collapse; margin-top: 6px; font-variant-numeric: tabular-nums; }
666
+ .ui-chart__table caption { text-align: left; color: var(--text-dim); padding-bottom: 4px; }
667
+ .ui-chart__table th, .ui-chart__table td { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--border); font-weight: 400; }
668
+ .ui-chart__table th { color: var(--text-dim); }
669
+
670
+ /* System page: three cards, then one chart per metric; one column below lg (992px). */
671
+ .system-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
672
+ .system-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
673
+ @media (max-width: 991px) {
674
+ .system-cards, .system-charts { grid-template-columns: minmax(0, 1fr); }
675
+ }
676
+
639
677
  .cockpit { overflow: auto; height: 100%; padding: 20px; }
640
678
  .cockpit__metrics { display: grid; grid-template-columns: minmax(260px, 1.2fr) repeat(3, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
679
+ .cockpit__machine-item { white-space: nowrap; color: var(--text); }
680
+ .cockpit__machine-item--warning { color: var(--yellow); }
681
+ .cockpit__machine-item--critical { color: var(--red); }
641
682
  .cockpit__health-row { margin-top: 12px; display: flex; align-items: baseline; gap: 12px; }
642
683
  .cockpit__health { font-size: 34px; text-transform: uppercase; font-weight: 800; }
643
684
  .cockpit__health--ok { color: var(--green); }
@@ -0,0 +1,263 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { apiFetch } from '@/lib/apiFetch';
5
+ import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
6
+ import { Meter, meterLevel, Page, PageHeader, Pill, Surface, Tabs, TimeSeriesChart } from '../components/ui';
7
+ import { SystemCardsSkeleton, SystemChartsSkeleton, SystemDescription } from './SystemSkeleton';
8
+
9
+ type Range = '1h' | '24h' | '7d' | '30d';
10
+ const RANGES: { id: Range; label: string; span: number }[] = [
11
+ { id: '1h', label: '1h', span: 3600 },
12
+ { id: '24h', label: '24h', span: 86_400 },
13
+ { id: '7d', label: '7d', span: 7 * 86_400 },
14
+ { id: '30d', label: '30d', span: 30 * 86_400 },
15
+ ];
16
+ const POLL_MS = 30_000;
17
+
18
+ /** Thresholds (%) for the meters: warning, critical. */
19
+ const CPU_RAM_LEVELS = [85, 95] as const;
20
+ const DISK_LEVELS = [80, 90] as const;
21
+
22
+ interface Disk {
23
+ mount: string;
24
+ roles: string[];
25
+ used_mb: number;
26
+ avail_mb: number;
27
+ total_mb: number;
28
+ percent: number;
29
+ }
30
+
31
+ interface MetricsPayload {
32
+ latest: {
33
+ ts: number;
34
+ age_s: number;
35
+ cpu: number | null;
36
+ ram_used_mb: number | null;
37
+ ram_total_mb: number | null;
38
+ swap_used_mb: number | null;
39
+ swap_total_mb: number | null;
40
+ load_avg_1m: number | null;
41
+ load_avg_5m: number | null;
42
+ load_avg_15m: number | null;
43
+ disks: Disk[];
44
+ } | null;
45
+ host: { hostname: string; platform: string; cores: number; uptime_s: number };
46
+ interval_s: number;
47
+ range: Range;
48
+ bucket_s: number;
49
+ history: { ts: number; cpu_avg: number | null; cpu_max: number | null; ram_avg: number | null; ram_max: number | null }[];
50
+ disk_history: { mount: string; points: { ts: number; percent: number | null }[] }[];
51
+ }
52
+
53
+ const ROLE_LABEL: Record<string, string> = { root: 'system', docker: 'Docker data', data: 'Rev4a data' };
54
+
55
+ function formatMb(mb: number | null | undefined): string {
56
+ if (mb === null || mb === undefined) return '—';
57
+ if (mb >= 1024 * 1024) return `${(mb / 1024 / 1024).toFixed(2)} TB`;
58
+ if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
59
+ return `${mb} MB`;
60
+ }
61
+
62
+ function formatUptime(s: number): string {
63
+ const d = Math.floor(s / 86_400);
64
+ const h = Math.floor((s % 86_400) / 3600);
65
+ const m = Math.floor((s % 3600) / 60);
66
+ return d > 0 ? `${d} d ${h} h` : h > 0 ? `${h} h ${m} min` : `${m} min`;
67
+ }
68
+
69
+ function percentOf(used: number | null, total: number | null): number | null {
70
+ return used !== null && total ? Math.round((used / total) * 1000) / 10 : null;
71
+ }
72
+
73
+ function timeFormatter(range: Range, timeZone: string): (ts: number) => string {
74
+ const opts: Intl.DateTimeFormatOptions =
75
+ range === '1h' || range === '24h'
76
+ ? { hour: '2-digit', minute: '2-digit', hour12: false }
77
+ : { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false };
78
+ const fmt = new Intl.DateTimeFormat('en-GB', { ...opts, timeZone });
79
+ return (ts) => fmt.format(new Date(ts * 1000));
80
+ }
81
+
82
+ export default function SystemPageClient() {
83
+ const [range, setRange] = useState<Range>('1h');
84
+ const [data, setData] = useState<MetricsPayload | null>(null);
85
+ const [loading, setLoading] = useState(true);
86
+ const [error, setError] = useState('');
87
+ const abortRef = useRef<AbortController | null>(null);
88
+
89
+ const load = useCallback(async (r: Range) => {
90
+ abortRef.current?.abort();
91
+ const controller = new AbortController();
92
+ abortRef.current = controller;
93
+ setLoading(true);
94
+ try {
95
+ const res = await apiFetch(`/api/metrics?range=${r}`, { signal: controller.signal });
96
+ const body = await res.json();
97
+ if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
98
+ setData(body as MetricsPayload);
99
+ setError('');
100
+ } catch (e: any) {
101
+ if (e?.name === 'AbortError') return;
102
+ setError(e?.message || 'Could not load the metrics');
103
+ } finally {
104
+ if (abortRef.current === controller) setLoading(false);
105
+ }
106
+ }, []);
107
+
108
+ useEffect(() => {
109
+ load(range);
110
+ const timer = setInterval(() => load(range), POLL_MS);
111
+ return () => {
112
+ clearInterval(timer);
113
+ abortRef.current?.abort();
114
+ };
115
+ }, [range, load]);
116
+
117
+ const latest = data?.latest ?? null;
118
+ const host = data?.host;
119
+ const span = RANGES.find((r) => r.id === range)!.span;
120
+ const to = Math.floor(Date.now() / 1000);
121
+ const from = to - span;
122
+ const timeZone = useRev4aTimezone();
123
+ const formatTime = timeFormatter(range, timeZone);
124
+ const stale = latest !== null && data !== null && latest.age_s > data.interval_s * 3;
125
+ const chartStale = loading && data !== null && data.range !== range;
126
+ // Before the first answer: placeholders shaped like the content (an error shows instead).
127
+ const firstLoad = data === null && !error;
128
+
129
+ const cpu = latest?.cpu ?? null;
130
+ const ramPct = latest ? percentOf(latest.ram_used_mb, latest.ram_total_mb) : null;
131
+
132
+ return (
133
+ <Page>
134
+ <PageHeader
135
+ eyebrow="Host"
136
+ title="System"
137
+ description={<SystemDescription host={host ? `${host.hostname} · ${host.cores} cores · up ${formatUptime(host.uptime_s)}` : null} />}
138
+ />
139
+
140
+ {error && <Surface tone="danger"><span style={{ color: 'var(--red)', fontSize: 12 }}>{error}</span></Surface>}
141
+
142
+ {data && !latest && (
143
+ <Surface tone="warning">
144
+ <div className="ui-kicker">No samples yet</div>
145
+ <p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
146
+ The Rev4a daemon records one every {data.interval_s} s. If this stays empty, check that `rev4a serve` is running.
147
+ </p>
148
+ </Surface>
149
+ )}
150
+
151
+ {stale && latest && (
152
+ <Surface tone="warning">
153
+ <div className="ui-kicker">Not collecting</div>
154
+ <p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
155
+ The latest sample is {formatUptime(latest.age_s)} old: the values below are not current. Check that the Rev4a daemon is running.
156
+ </p>
157
+ </Surface>
158
+ )}
159
+
160
+ {firstLoad ? <SystemCardsSkeleton /> : (
161
+ <div className="system-cards">
162
+ <Surface as="article">
163
+ <div className="ui-kicker">CPU</div>
164
+ <div className="ui-metric-value">{cpu !== null ? `${Math.round(cpu)}%` : '—'}</div>
165
+ {cpu !== null && <Meter percent={cpu} level={meterLevel(cpu, ...CPU_RAM_LEVELS)} label="CPU in use" />}
166
+ <div className="ui-key-values" style={{ marginTop: 12 }}>
167
+ <div className="ui-key-value"><span className="ui-muted">Cores</span><span>{host?.cores ?? '—'}</span></div>
168
+ <div className="ui-key-value">
169
+ <span className="ui-muted">Load 1 / 5 / 15 min</span>
170
+ <span>{latest ? `${latest.load_avg_1m ?? '—'} / ${latest.load_avg_5m ?? '—'} / ${latest.load_avg_15m ?? '—'}` : '—'}</span>
171
+ </div>
172
+ </div>
173
+ </Surface>
174
+
175
+ <Surface as="article">
176
+ <div className="ui-kicker">Memory</div>
177
+ <div className="ui-metric-value">{ramPct !== null ? `${Math.round(ramPct)}%` : '—'}</div>
178
+ {ramPct !== null && <Meter percent={ramPct} level={meterLevel(ramPct, ...CPU_RAM_LEVELS)} label="Memory in use" />}
179
+ <div className="ui-key-values" style={{ marginTop: 12 }}>
180
+ <div className="ui-key-value">
181
+ <span className="ui-muted">Used / total</span>
182
+ <span>{latest ? `${formatMb(latest.ram_used_mb)} / ${formatMb(latest.ram_total_mb)}` : '—'}</span>
183
+ </div>
184
+ <div className="ui-key-value">
185
+ <span className="ui-muted">Available</span>
186
+ <span>{latest && latest.ram_total_mb !== null && latest.ram_used_mb !== null ? formatMb(latest.ram_total_mb - latest.ram_used_mb) : '—'}</span>
187
+ </div>
188
+ <div className="ui-key-value">
189
+ <span className="ui-muted">Swap used / total</span>
190
+ <span>{latest && latest.swap_total_mb ? `${formatMb(latest.swap_used_mb)} / ${formatMb(latest.swap_total_mb)}` : 'none'}</span>
191
+ </div>
192
+ </div>
193
+ </Surface>
194
+
195
+ <Surface as="article">
196
+ <div className="ui-kicker">Storage</div>
197
+ {!latest?.disks.length && <div className="ui-metric-value">—</div>}
198
+ {latest?.disks.map((d) => (
199
+ <div key={d.mount} style={{ marginTop: 14 }}>
200
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
201
+ <span style={{ fontSize: 13 }}>{d.mount}</span>
202
+ <span style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
203
+ {d.roles.map((r) => <Pill key={r}>{ROLE_LABEL[r] ?? r}</Pill>)}
204
+ </span>
205
+ </div>
206
+ <Meter percent={d.percent} level={meterLevel(d.percent, ...DISK_LEVELS)} label={`Storage in use on ${d.mount}`} />
207
+ <div className="ui-key-value" style={{ marginTop: 6 }}>
208
+ <span className="ui-muted">{d.percent}% · {formatMb(d.used_mb)} of {formatMb(d.total_mb)}</span>
209
+ <span>{formatMb(d.avail_mb)} free</span>
210
+ </div>
211
+ </div>
212
+ ))}
213
+ </Surface>
214
+ </div>
215
+ )}
216
+
217
+ <Tabs items={RANGES.map((r) => ({ label: r.label, active: range === r.id, onClick: () => setRange(r.id) }))} />
218
+
219
+ {firstLoad ? <SystemChartsSkeleton /> : (
220
+ <div className="system-charts">
221
+ <Surface as="section">
222
+ <div className="ui-kicker">CPU · average and peak</div>
223
+ <TimeSeriesChart
224
+ label="CPU in use"
225
+ points={(data?.history ?? []).map((p) => ({ ts: p.ts, value: p.cpu_avg, peak: p.cpu_max }))}
226
+ from={from}
227
+ to={to}
228
+ bucket={data?.bucket_s ?? 30}
229
+ stale={chartStale}
230
+ formatTime={formatTime}
231
+ />
232
+ </Surface>
233
+ <Surface as="section">
234
+ <div className="ui-kicker">Memory · average and peak</div>
235
+ <TimeSeriesChart
236
+ label="Memory in use"
237
+ points={(data?.history ?? []).map((p) => ({ ts: p.ts, value: p.ram_avg, peak: p.ram_max }))}
238
+ from={from}
239
+ to={to}
240
+ bucket={data?.bucket_s ?? 30}
241
+ stale={chartStale}
242
+ formatTime={formatTime}
243
+ />
244
+ </Surface>
245
+ {(data?.disk_history ?? []).map((d) => (
246
+ <Surface as="section" key={d.mount}>
247
+ <div className="ui-kicker">Storage · {d.mount}</div>
248
+ <TimeSeriesChart
249
+ label={`Storage in use on ${d.mount}`}
250
+ points={d.points.map((p) => ({ ts: p.ts, value: p.percent }))}
251
+ from={from}
252
+ to={to}
253
+ bucket={data?.bucket_s ?? 30}
254
+ stale={chartStale}
255
+ formatTime={formatTime}
256
+ />
257
+ </Surface>
258
+ ))}
259
+ </div>
260
+ )}
261
+ </Page>
262
+ );
263
+ }
@@ -0,0 +1,115 @@
1
+ import { Skeleton } from '../components/Skeleton';
2
+ import { Surface } from '../components/ui';
3
+
4
+ /**
5
+ * Placeholders for the System page, used by `loading.tsx` while the route loads and by the
6
+ * page before its first answer. Each shimmer sits inside the same class as the text it
7
+ * stands in for (`ui-kicker`, `ui-metric-value`, `ui-key-value`, `ui-meter`), so the line
8
+ * boxes — and the card, grid and page heights — are the real ones and nothing jumps when
9
+ * the data lands. Measured against the loaded page, desktop and mobile.
10
+ */
11
+
12
+ /**
13
+ * The header description: a fixed sentence, then the host facts on one line of their own —
14
+ * cut with an ellipsis rather than wrapped, so a long hostname cannot change the height.
15
+ */
16
+ export function SystemDescription({ host }: { host: string | null }) {
17
+ return (
18
+ <>
19
+ CPU, memory and storage of the machine Rev4a runs on.
20
+ {/* width 0 + min-width 100%: the line takes the header's width without adding its own
21
+ (unwrapped) width to the header's minimum — else a long hostname widens the page. */}
22
+ <span style={{ display: 'block', width: 0, minWidth: '100%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
23
+ {host ?? <Skeleton as="span" inline width={260} height={12} />}
24
+ </span>
25
+ </>
26
+ );
27
+ }
28
+
29
+ function KeyValueRow({ label, value }: { label: number; value: number }) {
30
+ return (
31
+ <div className="ui-key-value">
32
+ <span><Skeleton as="span" inline width={label} height={11} /></span>
33
+ <span><Skeleton as="span" inline width={value} height={11} /></span>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ function MeterRow() {
39
+ return (
40
+ <div className="ui-meter">
41
+ <Skeleton height={6} style={{ flex: 1 }} />
42
+ </div>
43
+ );
44
+ }
45
+
46
+ function FigureCard({ title, rows }: { title: number; rows: [number, number][] }) {
47
+ return (
48
+ <Surface as="article">
49
+ <div className="ui-kicker"><Skeleton as="span" inline width={title} height={10} /></div>
50
+ <div className="ui-metric-value"><Skeleton as="span" inline width={72} height={26} accent /></div>
51
+ <MeterRow />
52
+ <div className="ui-key-values" style={{ marginTop: 12 }}>
53
+ {rows.map(([l, v], i) => <KeyValueRow key={i} label={l} value={v} />)}
54
+ </div>
55
+ </Surface>
56
+ );
57
+ }
58
+
59
+ export function SystemCardsSkeleton() {
60
+ return (
61
+ <div className="system-cards" aria-busy="true" aria-label="Loading machine metrics">
62
+ <FigureCard title={28} rows={[[40, 20], [120, 110]]} />
63
+ <FigureCard title={52} rows={[[80, 110], [64, 48], [110, 90]]} />
64
+ <Surface as="article">
65
+ <div className="ui-kicker"><Skeleton as="span" inline width={56} height={10} /></div>
66
+ <div style={{ marginTop: 14 }}>
67
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
68
+ <span style={{ fontSize: 13 }}><Skeleton as="span" inline width={24} height={12} /></span>
69
+ <span style={{ display: 'flex', gap: 4 }}>
70
+ <span className="ui-pill"><Skeleton as="span" inline width={40} height={9} /></span>
71
+ <span className="ui-pill"><Skeleton as="span" inline width={56} height={9} /></span>
72
+ </span>
73
+ </div>
74
+ <MeterRow />
75
+ <div style={{ marginTop: 6 }}>
76
+ <KeyValueRow label={150} value={80} />
77
+ </div>
78
+ </div>
79
+ </Surface>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ /** The chart grid: CPU, memory and one disk, each at the real plot height (160 px). */
85
+ export function SystemChartsSkeleton({ count = 3 }: { count?: number }) {
86
+ return (
87
+ <div className="system-charts" aria-hidden="true">
88
+ {Array.from({ length: count }).map((_, i) => (
89
+ <Surface as="section" key={i}>
90
+ <div className="ui-kicker"><Skeleton as="span" inline width={150} height={10} /></div>
91
+ <Skeleton height={160} />
92
+ <div className="ui-chart__table">
93
+ <div style={{ fontSize: 10 }}><Skeleton as="span" inline width={48} height={9} /></div>
94
+ </div>
95
+ </Surface>
96
+ ))}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ /** The range switch (1h · 24h · 7d · 30d) for the route's loading state, where the real tabs cannot render. */
102
+ export function SystemRangeSkeleton() {
103
+ return (
104
+ <div
105
+ aria-hidden="true"
106
+ style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 20, height: 44, alignItems: 'center' }}
107
+ >
108
+ {[18, 24, 18, 24].map((w, i) => (
109
+ <div key={i} style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
110
+ <Skeleton width={w} height={11} />
111
+ </div>
112
+ ))}
113
+ </div>
114
+ );
115
+ }
@@ -0,0 +1,13 @@
1
+ import { Page, PageHeader } from '../components/ui';
2
+ import { SystemCardsSkeleton, SystemChartsSkeleton, SystemDescription, SystemRangeSkeleton } from './SystemSkeleton';
3
+
4
+ export default function SystemLoading() {
5
+ return (
6
+ <Page>
7
+ <PageHeader eyebrow="Host" title="System" description={<SystemDescription host={null} />} />
8
+ <SystemCardsSkeleton />
9
+ <SystemRangeSkeleton />
10
+ <SystemChartsSkeleton />
11
+ </Page>
12
+ );
13
+ }