@flame0510/project-aether 1.7.0 → 1.9.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.
- package/README.md +3 -2
- package/app/agents/PageClient.tsx +3 -0
- package/app/agents/create/loading.tsx +51 -0
- package/app/agents/loading.tsx +2 -11
- package/app/api/assistant/route.ts +9 -2
- package/app/api/metrics/route.ts +126 -23
- package/app/api/system-health/route.ts +24 -20
- package/app/components/Sidebar.tsx +10 -0
- package/app/components/Skeleton.tsx +49 -1
- package/app/components/SystemCockpit.tsx +82 -2
- package/app/components/ui/Meter.tsx +34 -0
- package/app/components/ui/TimeSeriesChart.tsx +226 -0
- package/app/components/ui/index.ts +2 -0
- package/app/config/loading.tsx +4 -9
- package/app/containers/ContainersClient.tsx +4 -2
- package/app/containers/loading.tsx +2 -18
- package/app/containers/terminal/[id]/TerminalClient.tsx +230 -276
- package/app/containers/terminal/[id]/loading.tsx +20 -0
- package/app/crons/loading.tsx +4 -9
- package/app/gateway/loading.tsx +3 -7
- package/app/globals.css +51 -0
- package/app/lineage/loading.tsx +4 -9
- package/app/memory/loading.tsx +4 -9
- package/app/plugins/loading.tsx +4 -9
- package/app/skills/loading.tsx +4 -9
- package/app/system/PageClient.tsx +263 -0
- package/app/system/SystemSkeleton.tsx +115 -0
- package/app/system/loading.tsx +13 -0
- package/app/system/page.tsx +5 -0
- package/app/tools/loading.tsx +4 -9
- package/bin/rev4a.js +8 -4
- package/daemon.js +271 -190
- package/docs/ARCHITECTURE.md +49 -46
- package/docs/CONTAINER-TERMINAL.md +169 -261
- package/docs/DESIGN-SYSTEM.md +1 -0
- package/docs/FRONTEND-ARCHITECTURE.md +7 -2
- package/docs/REV4A.md +5 -3
- package/docs/dev/API-REFERENCE.md +61 -16
- package/docs/dev/DATABASE.md +65 -25
- package/docs/rag/DATA-FRESHNESS.md +13 -8
- package/docs/rag/GLOSSARY.md +7 -4
- package/docs/rag/REV4A-OVERVIEW.md +5 -2
- package/docs/rag/WHAT-I-CAN-ANSWER.md +1 -0
- package/lib/db-bootstrap.mjs +0 -11
- package/lib/metrics-db.ts +48 -0
- package/package.json +5 -1
- package/scripts/backup.sh +3 -3
- package/server.js +183 -0
- package/terminal-ws-server.js +329 -96
package/app/globals.css
CHANGED
|
@@ -636,8 +636,59 @@ 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
|
+
/* Container terminal: full screen, a bar on top, xterm.js below (it owns its own scrolling). */
|
|
671
|
+
.terminal-page { position: fixed; inset: 0; height: 100dvh; z-index: 9999; display: flex; flex-direction: column; background: var(--bg); color: var(--text); }
|
|
672
|
+
.terminal-page__bar { flex-shrink: 0; min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 16px; background: var(--bg2); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
|
673
|
+
.terminal-page__title { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
674
|
+
.terminal-page__container { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
675
|
+
.terminal-page__actions { display: flex; gap: 8px; }
|
|
676
|
+
.terminal-page__notice { flex-shrink: 0; padding: 8px 16px; font-size: 12px; color: var(--yellow); border-bottom: 1px solid var(--border); }
|
|
677
|
+
.terminal-page__screen { flex: 1; min-height: 0; padding: 8px 0 0 12px; overflow: hidden; }
|
|
678
|
+
.terminal-page__screen .xterm { height: 100%; }
|
|
679
|
+
|
|
680
|
+
/* System page: three cards, then one chart per metric; one column below lg (992px). */
|
|
681
|
+
.system-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
682
|
+
.system-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
|
683
|
+
@media (max-width: 991px) {
|
|
684
|
+
.system-cards, .system-charts { grid-template-columns: minmax(0, 1fr); }
|
|
685
|
+
}
|
|
686
|
+
|
|
639
687
|
.cockpit { overflow: auto; height: 100%; padding: 20px; }
|
|
640
688
|
.cockpit__metrics { display: grid; grid-template-columns: minmax(260px, 1.2fr) repeat(3, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
689
|
+
.cockpit__machine-item { white-space: nowrap; color: var(--text); }
|
|
690
|
+
.cockpit__machine-item--warning { color: var(--yellow); }
|
|
691
|
+
.cockpit__machine-item--critical { color: var(--red); }
|
|
641
692
|
.cockpit__health-row { margin-top: 12px; display: flex; align-items: baseline; gap: 12px; }
|
|
642
693
|
.cockpit__health { font-size: 34px; text-transform: uppercase; font-weight: 800; }
|
|
643
694
|
.cockpit__health--ok { color: var(--green); }
|
package/app/lineage/loading.tsx
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ListPageSkeleton } from '../components/Skeleton';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
<CardRowSkeleton count={5} height={48} />
|
|
7
|
-
</div>
|
|
8
|
-
);
|
|
9
|
-
|
|
10
|
-
export default PageSkeleton;
|
|
3
|
+
export default function LineageLoading() {
|
|
4
|
+
return <ListPageSkeleton />;
|
|
5
|
+
}
|
package/app/memory/loading.tsx
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ListPageSkeleton } from '../components/Skeleton';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
<CardRowSkeleton count={5} height={48} />
|
|
7
|
-
</div>
|
|
8
|
-
);
|
|
9
|
-
|
|
10
|
-
export default PageSkeleton;
|
|
3
|
+
export default function MemoryLoading() {
|
|
4
|
+
return <ListPageSkeleton />;
|
|
5
|
+
}
|
package/app/plugins/loading.tsx
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ListPageSkeleton } from '../components/Skeleton';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
<CardRowSkeleton count={5} height={48} />
|
|
7
|
-
</div>
|
|
8
|
-
);
|
|
9
|
-
|
|
10
|
-
export default PageSkeleton;
|
|
3
|
+
export default function PluginsLoading() {
|
|
4
|
+
return <ListPageSkeleton />;
|
|
5
|
+
}
|
package/app/skills/loading.tsx
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ListPageSkeleton } from '../components/Skeleton';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
<CardRowSkeleton count={5} height={48} />
|
|
7
|
-
</div>
|
|
8
|
-
);
|
|
9
|
-
|
|
10
|
-
export default PageSkeleton;
|
|
3
|
+
export default function SkillsLoading() {
|
|
4
|
+
return <ListPageSkeleton />;
|
|
5
|
+
}
|
|
@@ -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
|
+
}
|
package/app/tools/loading.tsx
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ListPageSkeleton } from '../components/Skeleton';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
<CardRowSkeleton count={5} height={48} />
|
|
7
|
-
</div>
|
|
8
|
-
);
|
|
9
|
-
|
|
10
|
-
export default PageSkeleton;
|
|
3
|
+
export default function ToolsLoading() {
|
|
4
|
+
return <ListPageSkeleton />;
|
|
5
|
+
}
|
package/bin/rev4a.js
CHANGED
|
@@ -513,14 +513,18 @@ function serve(port) {
|
|
|
513
513
|
|
|
514
514
|
const children = [];
|
|
515
515
|
const wsPort = process.env.TERMINAL_WS_PORT || '3741';
|
|
516
|
+
// Shared by server.js (which forwards the terminal WebSocket after checking the session)
|
|
517
|
+
// and the terminal server (which refuses anything without it). New at every start.
|
|
518
|
+
const terminalSecret = require('crypto').randomBytes(32).toString('hex');
|
|
516
519
|
|
|
517
520
|
log('SERVE', `Starting Rev4a on port ${port}…`);
|
|
518
521
|
|
|
519
|
-
// Next.js
|
|
520
|
-
|
|
522
|
+
// Next.js, through server.js: the same pages and API, plus the terminal WebSocket on
|
|
523
|
+
// this port (so the terminal needs no reverse proxy in front).
|
|
524
|
+
const next = spawn(process.execPath, ['server.js'], {
|
|
521
525
|
cwd: ROOT,
|
|
522
526
|
stdio: 'inherit',
|
|
523
|
-
env: childEnv,
|
|
527
|
+
env: { ...childEnv, TERMINAL_WS_PORT: wsPort, REV4A_TERMINAL_SECRET: terminalSecret },
|
|
524
528
|
});
|
|
525
529
|
children.push(next);
|
|
526
530
|
log('SERVE', `Next.js PID ${next.pid}`);
|
|
@@ -538,7 +542,7 @@ function serve(port) {
|
|
|
538
542
|
const ws = spawn(process.execPath, ['terminal-ws-server.js'], {
|
|
539
543
|
cwd: ROOT,
|
|
540
544
|
stdio: 'inherit',
|
|
541
|
-
env: { ...childEnv, TERMINAL_WS_PORT: wsPort },
|
|
545
|
+
env: { ...childEnv, TERMINAL_WS_PORT: wsPort, REV4A_TERMINAL_SECRET: terminalSecret },
|
|
542
546
|
});
|
|
543
547
|
children.push(ws);
|
|
544
548
|
log('SERVE', `Terminal WS PID ${ws.pid}`);
|