@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.
Files changed (49) hide show
  1. package/README.md +3 -2
  2. package/app/agents/PageClient.tsx +3 -0
  3. package/app/agents/create/loading.tsx +51 -0
  4. package/app/agents/loading.tsx +2 -11
  5. package/app/api/assistant/route.ts +9 -2
  6. package/app/api/metrics/route.ts +126 -23
  7. package/app/api/system-health/route.ts +24 -20
  8. package/app/components/Sidebar.tsx +10 -0
  9. package/app/components/Skeleton.tsx +49 -1
  10. package/app/components/SystemCockpit.tsx +82 -2
  11. package/app/components/ui/Meter.tsx +34 -0
  12. package/app/components/ui/TimeSeriesChart.tsx +226 -0
  13. package/app/components/ui/index.ts +2 -0
  14. package/app/config/loading.tsx +4 -9
  15. package/app/containers/ContainersClient.tsx +4 -2
  16. package/app/containers/loading.tsx +2 -18
  17. package/app/containers/terminal/[id]/TerminalClient.tsx +230 -276
  18. package/app/containers/terminal/[id]/loading.tsx +20 -0
  19. package/app/crons/loading.tsx +4 -9
  20. package/app/gateway/loading.tsx +3 -7
  21. package/app/globals.css +51 -0
  22. package/app/lineage/loading.tsx +4 -9
  23. package/app/memory/loading.tsx +4 -9
  24. package/app/plugins/loading.tsx +4 -9
  25. package/app/skills/loading.tsx +4 -9
  26. package/app/system/PageClient.tsx +263 -0
  27. package/app/system/SystemSkeleton.tsx +115 -0
  28. package/app/system/loading.tsx +13 -0
  29. package/app/system/page.tsx +5 -0
  30. package/app/tools/loading.tsx +4 -9
  31. package/bin/rev4a.js +8 -4
  32. package/daemon.js +271 -190
  33. package/docs/ARCHITECTURE.md +49 -46
  34. package/docs/CONTAINER-TERMINAL.md +169 -261
  35. package/docs/DESIGN-SYSTEM.md +1 -0
  36. package/docs/FRONTEND-ARCHITECTURE.md +7 -2
  37. package/docs/REV4A.md +5 -3
  38. package/docs/dev/API-REFERENCE.md +61 -16
  39. package/docs/dev/DATABASE.md +65 -25
  40. package/docs/rag/DATA-FRESHNESS.md +13 -8
  41. package/docs/rag/GLOSSARY.md +7 -4
  42. package/docs/rag/REV4A-OVERVIEW.md +5 -2
  43. package/docs/rag/WHAT-I-CAN-ANSWER.md +1 -0
  44. package/lib/db-bootstrap.mjs +0 -11
  45. package/lib/metrics-db.ts +48 -0
  46. package/package.json +5 -1
  47. package/scripts/backup.sh +3 -3
  48. package/server.js +183 -0
  49. package/terminal-ws-server.js +329 -96
package/README.md CHANGED
@@ -18,7 +18,8 @@
18
18
  - **Live event stream** — Real-time session lineage, agent spawning, cost reporting
19
19
  - **Workspace browser** — Browse and edit agent files live from the dashboard
20
20
  - **Provider proxy** — OpenAI-compatible chat completions proxy that routes through a shared key vault
21
- - **System health** — runtime sessions, event ingestion and errors, cron jobs and usage cost, with recommendations
21
+ - **System health** — runtime sessions, daemon heartbeat and errors, cron jobs and usage cost, with recommendations
22
+ - **System** — the host machine's CPU, memory, swap and storage, now and over 1h / 24h / 7d / 30d (sampled every 30 s, kept 30 days)
22
23
  - **Cost tracking** — Per-session, per-model, per-time-range usage and cost reporting
23
24
  - **PWA-ready** — Install on phone for mobile monitoring
24
25
 
@@ -73,7 +74,7 @@ already filled in — you normally never set these by hand. See
73
74
  | Variable | Default | Required | Description |
74
75
  |---|---|---|---|
75
76
  | `PORT` | `3740` | No | Dashboard HTTP port |
76
- | `TERMINAL_WS_PORT` | `3741` | No | Container terminal WebSocket port |
77
+ | `TERMINAL_WS_PORT` | `3741` | No | Loopback port of the container terminal server (the browser uses the dashboard's port) |
77
78
  | `REV4A_PASSWORD` | — | Yes | Dashboard login password (set via the setup wizard) |
78
79
  | `REV4A_TOKEN` | auto-generated | Yes | Bearer token for API routes and agent calls |
79
80
  | `REV4A_JWT_SECRET` | auto-generated | Yes | Secret for auth cookies |
@@ -284,6 +284,9 @@ function AgentListItem({
284
284
 
285
285
  export default function AgentsPageClient() {
286
286
  const router = useRouter();
287
+ // "+ New" navigates with router.push, which prefetches nothing: warm the route up front,
288
+ // so the click shows Create Agent (or its loading frame) at once.
289
+ useEffect(() => { router.prefetch('/agents/create'); }, [router]);
287
290
  const [agents, setAgents] = useState<Agent[]>([]);
288
291
  const [loading, setLoading] = useState(true);
289
292
  const [error, setError] = useState('');
@@ -0,0 +1,51 @@
1
+ import { Skeleton } from '../../components/Skeleton';
2
+
3
+ /**
4
+ * Create Agent while the route loads: the template step's own frame (same wrappers,
5
+ * paddings and font boxes as PageClient.tsx), not the Agents list's rows — so opening it
6
+ * from "+ New" shows its shape at once and nothing moves when it lands.
7
+ */
8
+
9
+ function Line({ width, size }: { width: number | string; size: number }) {
10
+ return <div style={{ fontSize: size }}><Skeleton as="span" inline width={width} height={Math.round(size * 0.8)} /></div>;
11
+ }
12
+
13
+ /** One card: an avatar, a name line, a description (one or two lines). */
14
+ function Card({ square, lines, padding }: { square: boolean; lines: number; padding: string }) {
15
+ return (
16
+ <div style={{ display: 'flex', alignItems: square ? 'flex-start' : 'center', gap: 12, padding, border: '1px solid var(--border)' }}>
17
+ <Skeleton width={square ? 32 : 28} height={square ? 32 : 28} variant={square ? 'block' : 'circle'} />
18
+ <div style={{ flex: 1, minWidth: 0 }}>
19
+ <Line width={110} size={14} />
20
+ <div style={{ marginTop: 2 }}>
21
+ {Array.from({ length: lines }).map((_, i) => <Line key={i} width={i === lines - 1 ? '45%' : '92%'} size={11} />)}
22
+ </div>
23
+ </div>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ export default function CreateAgentLoading() {
29
+ return (
30
+ <div aria-busy="true" style={{ minHeight: '100dvh', overflowY: 'auto', paddingBottom: 72, boxSizing: 'border-box', width: '100%' }}>
31
+ <div style={{ width: '100%', maxWidth: 760, margin: '0 auto', padding: '24px 16px', boxSizing: 'border-box' }}>
32
+ <div style={{ fontSize: 20, fontWeight: 500, marginBottom: 24 }}>
33
+ <Skeleton as="span" inline width={150} height={20} accent />
34
+ </div>
35
+ <div style={{ fontSize: 12, marginBottom: 16 }}><Skeleton as="span" inline width={230} height={10} /></div>
36
+ <div style={{ marginBottom: 24 }}>
37
+ <div style={{ fontSize: 10, marginBottom: 8 }}><Skeleton as="span" inline width={130} height={8} /></div>
38
+ <Card square lines={2} padding="16px" />
39
+ </div>
40
+ <div style={{ fontSize: 10, marginBottom: 8 }}><Skeleton as="span" inline width={140} height={8} /></div>
41
+ <div style={{ display: 'grid', gap: 8 }}>
42
+ {[0, 1, 2].map((i) => <Card key={i} square={false} lines={1} padding="14px 16px" />)}
43
+ </div>
44
+ <div style={{ marginTop: 24, display: 'flex', justifyContent: 'space-between' }}>
45
+ <Skeleton width={70} height={28} />
46
+ <Skeleton width={62} height={28} accent />
47
+ </div>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -1,14 +1,5 @@
1
- import { Skeleton, CardRowSkeleton } from '../components/Skeleton';
1
+ import { ListPageSkeleton } from '../components/Skeleton';
2
2
 
3
3
  export default function AgentsLoading() {
4
- return (
5
- <div style={{
6
- padding: '24px 20px',
7
- maxWidth: 1080,
8
- margin: '0 auto',
9
- }}>
10
- <Skeleton width={120} height={24} accent style={{ marginBottom: 20 }} />
11
- <CardRowSkeleton count={5} height={56} />
12
- </div>
13
- );
4
+ return <ListPageSkeleton rows={5} rowHeight={56} />;
14
5
  }
@@ -18,7 +18,7 @@ interface PageContext {
18
18
  const PAGE_CONTEXT: Record<string, PageContext> = {
19
19
  '/': {
20
20
  title: 'Dashboard',
21
- description: 'Main overview with live session list, cost summary by model, health cards for Rev4a\'s own runtime, cron and lineage, and a real-time event feed. It shows no CPU, RAM, disk or load average: those are collected by the daemon but no page displays them.',
21
+ description: 'Main overview with live session list, cost summary by model, health cards for Rev4a\'s own runtime, cron and lineage, and a real-time event feed. The Machine card shows the server\'s CPU, RAM and fullest disk right now, coloured past a threshold, with a link to the System page.',
22
22
  actions: ['Click any session row to open the Session Drawer for full details and tool call history', 'View cost breakdown by model and time period', 'See live events as they happen', 'Take a screenshot of the dashboard (mobile)'],
23
23
  hints: 'The Session Drawer shows you the complete tool call history for any session — just click a row.',
24
24
  },
@@ -46,6 +46,12 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
46
46
  actions: ['See all scheduled cron jobs at a glance', 'Check when each cron last ran and when it will run next', 'View job status (scheduled, running, completed, error)'],
47
47
  hints: 'Cron jobs use standard cron syntax. If a job shows "error" status, it means the last execution failed.',
48
48
  },
49
+ '/system': {
50
+ title: 'System',
51
+ description: 'The machine Rev4a runs on: CPU (percent, cores, load average 1/5/15 min), memory (used, available, swap) and storage (each disk with used and free space, and whether it is the system disk, Docker\'s data or Rev4a\'s data), now and over 1h, 24h, 7d or 30d. Sampled by the Rev4a daemon every 30 seconds, kept 30 days.',
52
+ actions: ['Read the current CPU, memory, swap and disk usage', 'Switch the charts between 1h, 24h, 7d and 30d', 'Hover a chart (or focus it and use the arrow keys) to read the average and peak at a point', 'Open a chart\'s Table view for the numbers'],
53
+ hints: 'Bars turn yellow then red past their thresholds: CPU and memory at 85% and 95%, disks at 80% and 90%. If the page says "Not collecting", the Rev4a daemon is not sampling: check that rev4a serve is running.',
54
+ },
49
55
  '/containers': {
50
56
  title: 'Containers',
51
57
  description: 'List of all Docker containers on the server, running or stopped (not just agents). Shows container ID, name, image, status, state, ports, and IP address.',
@@ -54,7 +60,7 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
54
60
  },
55
61
  '/containers/terminal/[id]': {
56
62
  title: 'Container Terminal',
57
- description: 'Interactive web terminal for a specific Docker container. Gives you a direct shell into any running container.',
63
+ description: 'Interactive web terminal for a specific Docker container: a real shell (Tab completion, history, top, vi, resizing). A dropped connection is resumed for 2 minutes; Close ends the shell.',
58
64
  actions: ['Run shell commands inside the container', 'Inspect files, processes, and logs live', 'Use any CLI tool available inside the container'],
59
65
  hints: 'This is a live terminal — changes you make inside the container are real. Be careful with destructive commands.',
60
66
  },
@@ -117,6 +123,7 @@ Navigation links inside Rev4a:
117
123
  - Gateway → /gateway
118
124
  - Cron Jobs → /crons
119
125
  - Containers → /containers
126
+ - System (CPU, RAM, disk of the server) → /system
120
127
  - Config → /config
121
128
  - Lineage → /lineage?period=7d
122
129
  - Memory / Context → /memory
@@ -1,34 +1,137 @@
1
+ import os from 'os';
1
2
  import { NextResponse, type NextRequest } from 'next/server';
2
- import { openDb } from '@/lib/db';
3
+ import { openMetricsDb } from '@/lib/metrics-db';
3
4
  import { requireAuthJWT } from '@/lib/rev4a-auth';
4
5
 
5
6
  export const dynamic = 'force-dynamic';
6
7
 
8
+ /** How often daemon.js samples the machine; the page calls a sample older than 3× this stale. */
9
+ const SAMPLE_INTERVAL_S = 30;
10
+
11
+ /**
12
+ * History ranges and their span in seconds; each is cut into about POINTS buckets, never
13
+ * shorter than two samples — a 30 s bucket on a 30 s timer that drifts a second would
14
+ * leave empty buckets, drawn as gaps, where no sample is missing.
15
+ */
16
+ const RANGES = { '1h': 3600, '24h': 86_400, '7d': 7 * 86_400, '30d': 30 * 86_400 } as const;
17
+ type Range = keyof typeof RANGES;
18
+ const POINTS = 120;
19
+
20
+ interface LatestRow {
21
+ ts: number;
22
+ cpu: number | null;
23
+ ram_used_mb: number | null;
24
+ ram_total_mb: number | null;
25
+ swap_used_mb: number | null;
26
+ swap_total_mb: number | null;
27
+ load_avg_1m: number | null;
28
+ load_avg_5m: number | null;
29
+ load_avg_15m: number | null;
30
+ }
31
+
32
+ interface DiskRow {
33
+ mount: string;
34
+ roles: string | null;
35
+ used_mb: number;
36
+ avail_mb: number;
37
+ total_mb: number;
38
+ }
39
+
40
+ /** Share of a filesystem in use, as `df` computes it: used / (used + avail). */
41
+ function diskPercent(used: number, avail: number): number {
42
+ return used + avail > 0 ? Math.round((used / (used + avail)) * 100) : 0;
43
+ }
44
+
45
+ /**
46
+ * Machine metrics of the host Rev4a runs on: the latest sample, live host facts, and a
47
+ * bucketed history for one range (average and peak per bucket).
48
+ */
7
49
  export async function GET(request: NextRequest): Promise<NextResponse> {
8
50
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
9
- const { searchParams } = new URL(request.url);
10
- const limit = Math.min(Number.parseInt(searchParams.get('limit') ?? '60', 10), 1000);
51
+ const param = new URL(request.url).searchParams.get('range') ?? '1h';
52
+ if (!Object.hasOwn(RANGES, param)) {
53
+ return NextResponse.json({ error: `range must be one of ${Object.keys(RANGES).join(', ')}` }, { status: 400 });
54
+ }
55
+ const range = param as Range;
56
+ const span = RANGES[range];
57
+ const bucket = Math.max(2 * SAMPLE_INTERVAL_S, Math.round(span / POINTS));
58
+ const now = Math.floor(Date.now() / 1000);
59
+ const since = now - span;
60
+
61
+ const host = {
62
+ hostname: os.hostname(),
63
+ platform: os.platform(),
64
+ cores: os.cpus().length,
65
+ uptime_s: Math.round(os.uptime()),
66
+ };
67
+ const base = { host, interval_s: SAMPLE_INTERVAL_S, range, bucket_s: bucket };
68
+
11
69
  try {
12
- const db = openDb();
13
- const latest = db
14
- .prepare(
15
- `SELECT cpu_percent as cpu, ram_used_mb, ram_total_mb, disk_used_gb, disk_total_gb,
16
- load_avg_1m as load_avg, ts FROM system_metrics ORDER BY ts DESC LIMIT 1`,
17
- )
18
- .get();
19
- const history = db
20
- .prepare(
21
- 'SELECT cpu_percent as cpu, ram_used_mb, ram_total_mb, ts FROM system_metrics ORDER BY ts DESC LIMIT ?',
22
- )
23
- .all(limit)
24
- .reverse();
25
- const stats_24h = db
26
- .prepare(
27
- 'SELECT ROUND(AVG(cpu_percent)) as cpu_avg, MAX(cpu_percent) as cpu_max FROM system_metrics WHERE ts > ?',
28
- )
29
- .get(Math.floor(Date.now() / 1000) - 86400);
30
- db.close();
31
- return NextResponse.json({ latest, history, stats_24h });
70
+ const db = openMetricsDb();
71
+ // No metrics database yet: the daemon has not sampled once.
72
+ if (!db) return NextResponse.json({ ...base, latest: null, history: [], disk_history: [] });
73
+ try {
74
+ const latest = db
75
+ .prepare(
76
+ `SELECT ts, cpu_percent AS cpu, ram_used_mb, ram_total_mb, swap_used_mb, swap_total_mb,
77
+ load_avg_1m, load_avg_5m, load_avg_15m
78
+ FROM system_metrics ORDER BY ts DESC LIMIT 1`,
79
+ )
80
+ .get() as LatestRow | undefined;
81
+
82
+ const disks = latest
83
+ ? (db
84
+ // One row per mount even if two samples share this ts (a second daemon, a clock step).
85
+ .prepare('SELECT mount, roles, used_mb, avail_mb, total_mb FROM system_disks WHERE ts = ? GROUP BY mount ORDER BY mount')
86
+ .all(latest.ts) as DiskRow[])
87
+ : [];
88
+
89
+ const history = db
90
+ .prepare(
91
+ `SELECT (ts / CAST(@bucket AS INTEGER)) * CAST(@bucket AS INTEGER) AS ts,
92
+ ROUND(AVG(cpu_percent), 1) AS cpu_avg,
93
+ ROUND(MAX(cpu_percent), 1) AS cpu_max,
94
+ ROUND(AVG(ram_used_mb * 100.0 / NULLIF(ram_total_mb, 0)), 1) AS ram_avg,
95
+ ROUND(MAX(ram_used_mb * 100.0 / NULLIF(ram_total_mb, 0)), 1) AS ram_max
96
+ FROM system_metrics WHERE ts > @since
97
+ GROUP BY ts / CAST(@bucket AS INTEGER) ORDER BY ts`,
98
+ )
99
+ .all({ bucket, since });
100
+
101
+ const diskRows = db
102
+ .prepare(
103
+ `SELECT mount, (ts / CAST(@bucket AS INTEGER)) * CAST(@bucket AS INTEGER) AS ts,
104
+ ROUND(AVG(used_mb * 100.0 / NULLIF(used_mb + avail_mb, 0)), 1) AS percent
105
+ FROM system_disks WHERE ts > @since
106
+ GROUP BY mount, ts / CAST(@bucket AS INTEGER) ORDER BY mount, ts`,
107
+ )
108
+ .all({ bucket, since }) as { mount: string; ts: number; percent: number | null }[];
109
+ const diskHistory = new Map<string, { ts: number; percent: number | null }[]>();
110
+ for (const r of diskRows) {
111
+ const points = diskHistory.get(r.mount) ?? [];
112
+ points.push({ ts: r.ts, percent: r.percent });
113
+ diskHistory.set(r.mount, points);
114
+ }
115
+
116
+ return NextResponse.json({
117
+ latest: latest
118
+ ? {
119
+ ...latest,
120
+ age_s: Math.max(0, now - latest.ts),
121
+ disks: disks.map((d) => ({
122
+ ...d,
123
+ roles: d.roles ? d.roles.split(',') : [],
124
+ percent: diskPercent(d.used_mb, d.avail_mb),
125
+ })),
126
+ }
127
+ : null,
128
+ ...base,
129
+ history,
130
+ disk_history: [...diskHistory].map(([mount, points]) => ({ mount, points })),
131
+ });
132
+ } finally {
133
+ db.close();
134
+ }
32
135
  } catch (e: unknown) {
33
136
  return NextResponse.json({ error: (e as Error).message }, { status: 500 });
34
137
  }
@@ -1,5 +1,6 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { openDb } from '@/lib/db';
3
+ import { latestMetricSample } from '@/lib/metrics-db';
3
4
  import { maybeSendAlert } from '@/lib/alerts';
4
5
  import { summarizeBilling } from '@/lib/billing';
5
6
  import { listOpenClawCronJobs } from '@/lib/openclaw-cron';
@@ -83,11 +84,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
83
84
  source: 'runtime',
84
85
  });
85
86
 
86
- const hasSystemMetrics = tableExists(db, 'system_metrics');
87
- const latestMetricRow = hasSystemMetrics
88
- ? (db.prepare('SELECT MAX(ts) AS latest FROM system_metrics').get() as { latest: number | null })
89
- : null;
90
- const latestMetricTs = getSeconds(latestMetricRow?.latest);
87
+ // The daemon's heartbeat: the age of its newest machine metrics sample (metrics.db, every
88
+ // 30 s on a timer of its own). It proves the daemon is alive — not that the OpenClaw session
89
+ // poll is ingesting, which runs separately and may have nothing to poll on this host.
90
+ const metricSample = latestMetricSample();
91
+ const hasSystemMetrics = metricSample.available;
92
+ const latestMetricTs = getSeconds(metricSample.ts);
91
93
  const latestMetricAgeSeconds = latestMetricTs ? Math.floor(Date.now() / 1000) - latestMetricTs : null;
92
94
  const runtimeFreshnessHealth: Health = !hasSystemMetrics
93
95
  ? 'ok'
@@ -98,15 +100,17 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
98
100
  : 'ok';
99
101
 
100
102
  checks.push({
101
- id: 'runtime.ingestion',
102
- label: 'Runtime ingestion',
103
+ id: 'runtime.heartbeat',
104
+ label: 'Daemon heartbeat',
103
105
  health: runtimeFreshnessHealth,
104
106
  value: hasSystemMetrics ? (latestMetricTs ? 'fresh' : 'missing') : 'n/d',
105
- details: !hasSystemMetrics
106
- ? 'Heartbeat metrics unavailable in this DB'
107
- : latestMetricAgeSeconds === null
108
- ? 'No heartbeat metrics detected'
109
- : `last heartbeat metrics ${latestMetricAgeSeconds}s ago`,
107
+ details: metricSample.error
108
+ ? `metrics.db unreadable: ${metricSample.error}`
109
+ : !hasSystemMetrics
110
+ ? 'No machine metrics database yet'
111
+ : latestMetricAgeSeconds === null
112
+ ? 'No machine metrics sample yet'
113
+ : `last machine metrics sample ${latestMetricAgeSeconds}s ago`,
110
114
  source: 'runtime',
111
115
  });
112
116
 
@@ -115,11 +119,11 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
115
119
  id: 'runtime.check-freshness',
116
120
  severity: 'warning',
117
121
  source: 'runtime',
118
- title: 'Check Rev4a runtime heartbeat',
122
+ title: 'Check that the Rev4a daemon is running',
119
123
  details: latestMetricAgeSeconds === null
120
- ? 'No heartbeat metrics available from runtime.'
121
- : `Heartbeat metrics stuck for ${latestMetricAgeSeconds}s (threshold ${staleThresholdSeconds}s).`,
122
- actionHref: '/lineage',
124
+ ? 'No machine metrics sample yet.'
125
+ : `No machine metrics sample for ${latestMetricAgeSeconds}s (threshold ${staleThresholdSeconds}s).`,
126
+ actionHref: '/system',
123
127
  dismissible: false,
124
128
  createdAt,
125
129
  });
@@ -130,11 +134,11 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
130
134
  kind: 'db-freshness',
131
135
  title: 'Rev4a DB freshness',
132
136
  message: !hasSystemMetrics
133
- ? 'system_metrics table unavailable: freshness runtime not verifiable.'
137
+ ? 'No machine metrics database: the daemon heartbeat cannot be verified.'
134
138
  : latestMetricAgeSeconds === null
135
- ? 'No heartbeat metrics detected.'
136
- : `Last heartbeat metrics ${latestMetricAgeSeconds}s ago (threshold ${staleThresholdSeconds}s).`,
137
- resolvedMessage: 'DB Rev4a fresh again.',
139
+ ? 'No machine metrics sample yet.'
140
+ : `Last machine metrics sample ${latestMetricAgeSeconds}s ago (threshold ${staleThresholdSeconds}s).`,
141
+ resolvedMessage: 'Rev4a daemon sampling again.',
138
142
  stale: hasSystemMetrics ? (latestMetricAgeSeconds === null ? true : latestMetricAgeSeconds > staleThresholdSeconds) : false,
139
143
  });
140
144
 
@@ -59,6 +59,16 @@ const NAV: NavItem[] = [
59
59
  </svg>
60
60
  ),
61
61
  },
62
+ {
63
+ href: '/system',
64
+ label: 'System',
65
+ icon: (
66
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
67
+ <rect x="1.5" y="2.5" width="13" height="11" stroke="currentColor" strokeWidth="1.3" />
68
+ <path d="M4 10.5l2.5-3 2 2 3.5-4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
69
+ </svg>
70
+ ),
71
+ },
62
72
  {
63
73
  href: '/workspace',
64
74
  label: 'Workspace',
@@ -11,6 +11,8 @@ type SkeletonProps = {
11
11
  inline?: boolean;
12
12
  className?: string;
13
13
  style?: React.CSSProperties;
14
+ /** `span` for a placeholder inside text (a `<p>`, a `<span>`), where a `<div>` is invalid HTML and the parser would split the paragraph. */
15
+ as?: 'div' | 'span';
14
16
  };
15
17
 
16
18
  export function Skeleton({
@@ -21,6 +23,7 @@ export function Skeleton({
21
23
  inline = false,
22
24
  className,
23
25
  style,
26
+ as: Tag = 'div',
24
27
  }: SkeletonProps) {
25
28
  const base: React.CSSProperties = {
26
29
  display: inline ? 'inline-block' : 'block',
@@ -35,7 +38,7 @@ export function Skeleton({
35
38
  verticalAlign: inline ? 'middle' : undefined,
36
39
  ...style,
37
40
  };
38
- return <div className={className} style={base} aria-hidden="true" />;
41
+ return <Tag className={className} style={base} aria-hidden="true" />;
39
42
  }
40
43
 
41
44
  /** Tree skeleton — simulates file tree structure like workspace sidebar */
@@ -334,3 +337,48 @@ export function AgentSyncRowsSkeleton({ rows = 3 }: { rows?: number }) {
334
337
  </div>
335
338
  );
336
339
  }
340
+
341
+ /**
342
+ * The frame of a route's `loading.tsx`: full width up to `maxWidth`, centred, scrollable.
343
+ *
344
+ * `width: 100%` is the point. The app shell's content area is a column flexbox, and a
345
+ * child with `margin: 0 auto` but no width is not stretched by it — it shrinks to its
346
+ * content. Every loader used to do exactly that, so its only fixed-width piece (a 120 px
347
+ * title bar) set the width of everything, and the rows came out 120 px wide.
348
+ */
349
+ export function RouteLoadingShell({ children, maxWidth = 1080, padding = '24px 20px' }: {
350
+ children: React.ReactNode;
351
+ maxWidth?: number;
352
+ padding?: string;
353
+ }) {
354
+ return (
355
+ <div
356
+ aria-busy="true"
357
+ style={{
358
+ width: '100%',
359
+ maxWidth,
360
+ margin: '0 auto',
361
+ padding,
362
+ boxSizing: 'border-box',
363
+ height: '100%',
364
+ overflowY: 'auto',
365
+ }}
366
+ >
367
+ {children}
368
+ </div>
369
+ );
370
+ }
371
+
372
+ /** A title bar over a list of rows — the loader of the list pages. */
373
+ export function ListPageSkeleton({ rows = 5, rowHeight = 48, titleWidth = 120 }: {
374
+ rows?: number;
375
+ rowHeight?: number;
376
+ titleWidth?: number;
377
+ }) {
378
+ return (
379
+ <RouteLoadingShell>
380
+ <Skeleton width={titleWidth} height={20} accent style={{ marginBottom: 20 }} />
381
+ <CardRowSkeleton count={rows} height={rowHeight} />
382
+ </RouteLoadingShell>
383
+ );
384
+ }
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useState } from 'react';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import Link from 'next/link';
5
5
  import type { Costs, FilterConfig, Session, SessionEvent } from '@/lib/types';
6
6
  import { isSessionActive } from '@/lib/patterns/sessionPresentation';
7
7
  import { SkeletonLines, SkeletonMetric } from './Skeleton';
8
- import { Metric, StatusCard, Surface, toneFromHealth } from './ui';
8
+ import { Metric, StatusCard, Surface, meterLevel, toneFromHealth, type MeterLevel, type Tone } from './ui';
9
9
  import { apiFetch } from '@/lib/apiFetch';
10
10
  import LiveFeed from './LiveFeed';
11
11
  import CostBreakdown from './CostBreakdown';
@@ -38,6 +38,50 @@ interface SystemHealthPayload {
38
38
  generatedAt: string;
39
39
  }
40
40
 
41
+ interface MachineNow {
42
+ cpu: number | null;
43
+ ram: number | null;
44
+ disk: number | null;
45
+ /** `empty`: no sample yet; `stale`: the newest is older than 3 intervals; `error`: the last refresh failed (figures are the previous ones). */
46
+ state: 'ok' | 'empty' | 'stale' | 'error';
47
+ }
48
+
49
+ const LEVEL_TONE: Record<MeterLevel, Tone> = { ok: 'neutral', warning: 'warning', critical: 'danger' };
50
+
51
+ /** The host machine right now (CPU, RAM, fullest disk) from /api/metrics; details on /system. */
52
+ function useMachineNow(): MachineNow | null {
53
+ const [now, setNow] = useState<MachineNow | null>(null);
54
+ const abortRef = useRef<AbortController | null>(null);
55
+ useEffect(() => {
56
+ const load = () => {
57
+ abortRef.current?.abort();
58
+ const controller = new AbortController();
59
+ abortRef.current = controller;
60
+ apiFetch('/api/metrics?range=1h', { signal: controller.signal })
61
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
62
+ .then((d) => {
63
+ const l = d.latest;
64
+ if (!l) { setNow({ cpu: null, ram: null, disk: null, state: 'empty' }); return; }
65
+ const disks: { percent: number }[] = l.disks ?? [];
66
+ setNow({
67
+ cpu: l.cpu === null ? null : Math.round(l.cpu),
68
+ ram: l.ram_total_mb ? Math.round((l.ram_used_mb / l.ram_total_mb) * 100) : null,
69
+ disk: disks.length ? Math.max(...disks.map((x) => x.percent)) : null,
70
+ state: l.age_s > d.interval_s * 3 ? 'stale' : 'ok',
71
+ });
72
+ })
73
+ .catch((e) => {
74
+ if (e?.name === 'AbortError') return;
75
+ setNow((prev) => ({ cpu: prev?.cpu ?? null, ram: prev?.ram ?? null, disk: prev?.disk ?? null, state: 'error' }));
76
+ });
77
+ };
78
+ load();
79
+ const t = setInterval(load, 30_000);
80
+ return () => { clearInterval(t); abortRef.current?.abort(); };
81
+ }, []);
82
+ return now;
83
+ }
84
+
41
85
  interface SystemCockpitProps {
42
86
  sessions: Session[];
43
87
  events: SessionEvent[];
@@ -75,6 +119,7 @@ export default function SystemCockpit({
75
119
  loading = false,
76
120
  }: SystemCockpitProps) {
77
121
  const [systemHealth, setSystemHealth] = useState<SystemHealthPayload | null>(null);
122
+ const machine = useMachineNow();
78
123
 
79
124
  useEffect(() => {
80
125
  const load = () => {
@@ -108,6 +153,19 @@ export default function SystemCockpit({
108
153
  const health = systemHealth?.health ?? 'warning';
109
154
  const recommendations = systemHealth?.recommendations ?? [];
110
155
  const runtimeFreshness = runtimeCheck?.details ?? fmtAge(metrics.latest);
156
+ const pct = (v: number | null | undefined) => (v === null || v === undefined ? '—' : `${v}%`);
157
+ // Each figure with its level, on the thresholds the System page uses (CPU/RAM 85/95, disk 80/90).
158
+ const machineItems: { name: string; value: number | null; level: MeterLevel }[] = machine
159
+ ? [
160
+ { name: 'CPU', value: machine.cpu, level: machine.cpu === null ? 'ok' : meterLevel(machine.cpu, 85, 95) },
161
+ { name: 'RAM', value: machine.ram, level: machine.ram === null ? 'ok' : meterLevel(machine.ram, 85, 95) },
162
+ { name: 'Disk', value: machine.disk, level: machine.disk === null ? 'ok' : meterLevel(machine.disk, 80, 90) },
163
+ ]
164
+ : [];
165
+ const machineIssues = machineItems.filter((i) => i.level !== 'ok');
166
+ const machineTone: Tone = machine && machine.state !== 'ok'
167
+ ? 'warning'
168
+ : LEVEL_TONE[machineIssues.some((i) => i.level === 'critical') ? 'critical' : machineIssues.length ? 'warning' : 'ok'];
111
169
 
112
170
  return (
113
171
  <section className="cockpit">
@@ -121,6 +179,28 @@ export default function SystemCockpit({
121
179
  <div className="ui-muted" style={{ marginTop: 10, fontSize: 11 }}>Server-side monitor from `/api/system-health`.</div>
122
180
  </Surface>
123
181
  <Metric title="Active sessions" value={metrics.active} subtitle={`${sessions.length} total`} loading={loading} />
182
+ {/* A Surface, not a Metric: a danger Metric paints its whole value red, and only the
183
+ figure past its threshold should be. */}
184
+ <Surface as="article" tone={machineTone}>
185
+ <div className="ui-kicker">Machine</div>
186
+ <div style={{ display: 'flex', flexWrap: 'wrap', columnGap: 12, marginTop: 14, fontSize: 15, fontWeight: 700 }}>
187
+ {machineItems.length ? machineItems.map((i) => (
188
+ <span key={i.name} className={`cockpit__machine-item cockpit__machine-item--${i.level}`}>{i.name} {pct(i.value)}</span>
189
+ )) : '—'}
190
+ </div>
191
+ <div className="ui-muted ui-metric-subtitle">
192
+ {machine?.state === 'error'
193
+ ? 'Metrics unavailable. '
194
+ : machine?.state === 'empty'
195
+ ? 'No samples yet. '
196
+ : machine?.state === 'stale'
197
+ ? 'Not collecting. '
198
+ : machineIssues.length
199
+ ? `${machineIssues.map((i) => `${i.name} ${i.level === 'critical' ? 'critical' : 'high'}`).join(', ')}. `
200
+ : ''}
201
+ <Link href="/system" className="ui-link">Open System →</Link>
202
+ </div>
203
+ </Surface>
124
204
  <Metric title="Usage cost" value={String(costCheck?.value ?? fmtMoney(0))} subtitle={costCheck?.details ?? `DB estimate ${fmtMoney(costs.today)}`} tone="accent" loading={systemLoading} />
125
205
  </div>
126
206
 
@@ -0,0 +1,34 @@
1
+ export type MeterLevel = 'ok' | 'warning' | 'critical';
2
+
3
+ /** The level a percentage falls in, given its warning and critical thresholds. */
4
+ export function meterLevel(percent: number, warning: number, critical: number): MeterLevel {
5
+ if (percent >= critical) return 'critical';
6
+ if (percent >= warning) return 'warning';
7
+ return 'ok';
8
+ }
9
+
10
+ const LEVEL_LABEL: Record<MeterLevel, string> = { ok: 'ok', warning: 'high', critical: 'critical' };
11
+
12
+ /**
13
+ * A horizontal fill for a 0-100 % share. The fill carries the level (accent → warning →
14
+ * danger), the track is a dim step behind it, and the level is also written out, so it
15
+ * never rests on colour alone.
16
+ */
17
+ export function Meter({ percent, level, label }: { percent: number; level: MeterLevel; label: string }) {
18
+ const clamped = Math.max(0, Math.min(100, percent));
19
+ return (
20
+ <div className="ui-meter">
21
+ <div
22
+ className="ui-meter__track"
23
+ role="meter"
24
+ aria-label={label}
25
+ aria-valuemin={0}
26
+ aria-valuemax={100}
27
+ aria-valuenow={Math.round(clamped)}
28
+ >
29
+ <div className={`ui-meter__fill ui-meter__fill--${level}`} style={{ width: `${clamped}%` }} />
30
+ </div>
31
+ {level !== 'ok' && <span className={`ui-meter__level ui-meter__level--${level}`}>{LEVEL_LABEL[level]}</span>}
32
+ </div>
33
+ );
34
+ }