@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.
- package/README.md +2 -1
- package/app/api/assistant/route.ts +8 -1
- 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 +4 -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/globals.css +41 -0
- 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/daemon.js +271 -190
- package/docs/ARCHITECTURE.md +37 -24
- package/docs/DESIGN-SYSTEM.md +1 -0
- package/docs/FRONTEND-ARCHITECTURE.md +6 -2
- package/docs/REV4A.md +2 -1
- package/docs/dev/API-REFERENCE.md +40 -11
- package/docs/dev/DATABASE.md +65 -25
- package/docs/rag/DATA-FRESHNESS.md +13 -8
- package/docs/rag/GLOSSARY.md +6 -3
- package/docs/rag/REV4A-OVERVIEW.md +4 -1
- 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 +1 -1
- package/scripts/backup.sh +3 -3
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,
|
|
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
|
|
|
@@ -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.
|
|
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.',
|
|
@@ -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
|
package/app/api/metrics/route.ts
CHANGED
|
@@ -1,34 +1,137 @@
|
|
|
1
|
+
import os from 'os';
|
|
1
2
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import {
|
|
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
|
|
10
|
-
|
|
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 =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
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.
|
|
102
|
-
label: '
|
|
103
|
+
id: 'runtime.heartbeat',
|
|
104
|
+
label: 'Daemon heartbeat',
|
|
103
105
|
health: runtimeFreshnessHealth,
|
|
104
106
|
value: hasSystemMetrics ? (latestMetricTs ? 'fresh' : 'missing') : 'n/d',
|
|
105
|
-
details:
|
|
106
|
-
?
|
|
107
|
-
:
|
|
108
|
-
? 'No
|
|
109
|
-
:
|
|
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
|
|
122
|
+
title: 'Check that the Rev4a daemon is running',
|
|
119
123
|
details: latestMetricAgeSeconds === null
|
|
120
|
-
? 'No
|
|
121
|
-
: `
|
|
122
|
-
actionHref: '/
|
|
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
|
-
? '
|
|
137
|
+
? 'No machine metrics database: the daemon heartbeat cannot be verified.'
|
|
134
138
|
: latestMetricAgeSeconds === null
|
|
135
|
-
? 'No
|
|
136
|
-
: `Last
|
|
137
|
-
resolvedMessage: '
|
|
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 <
|
|
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 */
|
|
@@ -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
|
+
}
|