@flame0510/project-aether 1.6.2 → 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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/agent-templates/atlas/HEARTBEAT.md +1 -1
  4. package/app/agents/ImageDownloadBanner.tsx +171 -37
  5. package/app/api/agents/download-image/route.ts +29 -5
  6. package/app/api/agents/image-status/route.ts +17 -2
  7. package/app/api/assistant/route.ts +21 -5
  8. package/app/api/auth/login/route.ts +2 -2
  9. package/app/api/metrics/route.ts +126 -23
  10. package/app/api/setup/agent-image/route.ts +6 -4
  11. package/app/api/system-health/route.ts +25 -21
  12. package/app/components/DashboardToolbar.tsx +2 -2
  13. package/app/components/LineageGraphPage.tsx +4 -4
  14. package/app/components/SessionDrawer.tsx +8 -8
  15. package/app/components/Sidebar.tsx +10 -0
  16. package/app/components/Skeleton.tsx +4 -1
  17. package/app/components/SystemCockpit.tsx +83 -3
  18. package/app/components/ui/Meter.tsx +34 -0
  19. package/app/components/ui/TimeSeriesChart.tsx +226 -0
  20. package/app/components/ui/index.ts +2 -0
  21. package/app/globals.css +42 -1
  22. package/app/setup/PageClient.tsx +1 -1
  23. package/app/system/PageClient.tsx +263 -0
  24. package/app/system/SystemSkeleton.tsx +115 -0
  25. package/app/system/loading.tsx +13 -0
  26. package/app/system/page.tsx +5 -0
  27. package/bin/postinstall.js +5 -1
  28. package/daemon.js +274 -214
  29. package/docs/ARCHITECTURE.md +65 -34
  30. package/docs/CONTAINER-TERMINAL.md +17 -8
  31. package/docs/DESIGN-SYSTEM.md +22 -12
  32. package/docs/FRONTEND-ARCHITECTURE.md +8 -4
  33. package/docs/REV4A.md +37 -86
  34. package/docs/dev/API-REFERENCE.md +102 -19
  35. package/docs/dev/DATABASE.md +79 -28
  36. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
  37. package/docs/rag/DATA-FRESHNESS.md +31 -15
  38. package/docs/rag/GLOSSARY.md +8 -5
  39. package/docs/rag/REV4A-OVERVIEW.md +14 -7
  40. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -3
  41. package/lib/agent-images.ts +43 -14
  42. package/lib/buildAgentImage.ts +142 -6
  43. package/lib/db-bootstrap.mjs +0 -11
  44. package/lib/metrics-db.ts +48 -0
  45. package/lib/patterns/sessionPresentation.ts +4 -2
  46. package/lib/rev4a-auth.d.ts +1 -0
  47. package/lib/rev4a-auth.js +18 -2
  48. package/next.config.mjs +9 -1
  49. package/package.json +2 -2
  50. package/scripts/backup.sh +48 -54
  51. package/scripts/check-language.mjs +21 -3
  52. package/scripts/restore.sh +77 -59
@@ -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); }
@@ -1387,7 +1428,7 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
1387
1428
  white-space: nowrap;
1388
1429
  }
1389
1430
 
1390
- /* Padding bottom per non coprire contenuti — handled by .app-shell__content */
1431
+ /* Bottom padding so content is not covered — handled by .app-shell__content */
1391
1432
 
1392
1433
  /* Chat button raised above the bottom navbar */
1393
1434
  .ochat__trigger {
@@ -149,7 +149,7 @@ export default function SetupPageClient() {
149
149
  <div style={{ display: 'grid', gap: 6, textAlign: 'left', padding: '10px 12px', background: 'rgba(52,211,153,0.06)', border: '1px solid rgba(52,211,153,0.15)' }}>
150
150
  {[
151
151
  'Set admin password',
152
- 'Agent base image is built by the setup script',
152
+ 'Agent base image: download it from the banner on the Agents page',
153
153
  'Next: add providers & agents in the wizard',
154
154
  ].map((t) => (
155
155
  <div key={t} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--text)' }}>
@@ -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
+ }