@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.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/agent-templates/atlas/HEARTBEAT.md +1 -1
- package/app/agents/ImageDownloadBanner.tsx +171 -37
- package/app/api/agents/download-image/route.ts +29 -5
- package/app/api/agents/image-status/route.ts +17 -2
- package/app/api/assistant/route.ts +21 -5
- package/app/api/auth/login/route.ts +2 -2
- package/app/api/metrics/route.ts +126 -23
- package/app/api/setup/agent-image/route.ts +6 -4
- package/app/api/system-health/route.ts +25 -21
- package/app/components/DashboardToolbar.tsx +2 -2
- package/app/components/LineageGraphPage.tsx +4 -4
- package/app/components/SessionDrawer.tsx +8 -8
- package/app/components/Sidebar.tsx +10 -0
- package/app/components/Skeleton.tsx +4 -1
- package/app/components/SystemCockpit.tsx +83 -3
- 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 +42 -1
- package/app/setup/PageClient.tsx +1 -1
- 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/bin/postinstall.js +5 -1
- package/daemon.js +274 -214
- package/docs/ARCHITECTURE.md +65 -34
- package/docs/CONTAINER-TERMINAL.md +17 -8
- package/docs/DESIGN-SYSTEM.md +22 -12
- package/docs/FRONTEND-ARCHITECTURE.md +8 -4
- package/docs/REV4A.md +37 -86
- package/docs/dev/API-REFERENCE.md +102 -19
- package/docs/dev/DATABASE.md +79 -28
- package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
- package/docs/rag/DATA-FRESHNESS.md +31 -15
- package/docs/rag/GLOSSARY.md +8 -5
- package/docs/rag/REV4A-OVERVIEW.md +14 -7
- package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -3
- package/lib/agent-images.ts +43 -14
- package/lib/buildAgentImage.ts +142 -6
- package/lib/db-bootstrap.mjs +0 -11
- package/lib/metrics-db.ts +48 -0
- package/lib/patterns/sessionPresentation.ts +4 -2
- package/lib/rev4a-auth.d.ts +1 -0
- package/lib/rev4a-auth.js +18 -2
- package/next.config.mjs +9 -1
- package/package.json +2 -2
- package/scripts/backup.sh +48 -54
- package/scripts/check-language.mjs +21 -3
- package/scripts/restore.sh +77 -59
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
|
}
|
|
@@ -45,6 +45,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const encoder = new TextEncoder();
|
|
48
|
+
// Aborted when the client goes away (the stream's cancel()), which stops docker.
|
|
49
|
+
const abort = new AbortController();
|
|
48
50
|
|
|
49
51
|
const stream = new ReadableStream({
|
|
50
52
|
async start(controller) {
|
|
@@ -54,10 +56,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
54
56
|
} catch { /* stream closed */ }
|
|
55
57
|
};
|
|
56
58
|
|
|
57
|
-
const ctrl = controller as ReadableStreamDefaultController & { signal?: AbortSignal };
|
|
58
|
-
|
|
59
59
|
try {
|
|
60
|
-
await downloadAgentImage({ onEvent: send, signal:
|
|
60
|
+
await downloadAgentImage({ onEvent: send, signal: abort.signal });
|
|
61
61
|
} catch (e: unknown) {
|
|
62
62
|
send(JSON.stringify({
|
|
63
63
|
type: 'error',
|
|
@@ -66,7 +66,9 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
66
66
|
}
|
|
67
67
|
controller.close();
|
|
68
68
|
},
|
|
69
|
-
|
|
69
|
+
// A ReadableStreamDefaultController has no signal of its own; this is the hook the
|
|
70
|
+
// runtime calls when the client disconnects.
|
|
71
|
+
cancel() { abort.abort(); },
|
|
70
72
|
});
|
|
71
73
|
|
|
72
74
|
return new Response(stream, {
|
|
@@ -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
|
|
|
@@ -190,7 +194,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
190
194
|
severity: 'warning',
|
|
191
195
|
source: 'cost',
|
|
192
196
|
title: 'High usage-based cost',
|
|
193
|
-
details: `Provider usage-based today: $${billing.usageBasedCost.toFixed(2)}.
|
|
197
|
+
details: `Provider usage-based today: $${billing.usageBasedCost.toFixed(2)}. Counts OpenRouter and the other pay-per-use providers, not the fixed plans.`,
|
|
194
198
|
actionHref: '/gateway',
|
|
195
199
|
dismissible: true,
|
|
196
200
|
createdAt,
|
|
@@ -12,7 +12,7 @@ interface DashboardToolbarProps {
|
|
|
12
12
|
export default function DashboardToolbar({ filter, onChange, agents = ['all'] }: DashboardToolbarProps) {
|
|
13
13
|
return (
|
|
14
14
|
<section className="toolbar">
|
|
15
|
-
<span className="toolbar-label">
|
|
15
|
+
<span className="toolbar-label">AGENTS</span>
|
|
16
16
|
|
|
17
17
|
{agents.map((name) => (
|
|
18
18
|
<button
|
|
@@ -44,7 +44,7 @@ export default function DashboardToolbar({ filter, onChange, agents = ['all'] }:
|
|
|
44
44
|
</button>
|
|
45
45
|
|
|
46
46
|
<span className="toolbar-sep">|</span>
|
|
47
|
-
<span className="toolbar-label">
|
|
47
|
+
<span className="toolbar-label">PERIOD</span>
|
|
48
48
|
|
|
49
49
|
{PERIODS.map((p) => (
|
|
50
50
|
<button
|
|
@@ -48,9 +48,9 @@ export default function LineageGraphPage({ initialCosts }: LineageGraphPageProps
|
|
|
48
48
|
{isMobile && (
|
|
49
49
|
<div style={{ display: 'flex', gap: 8, padding: '8px 12px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)' }}>
|
|
50
50
|
{[
|
|
51
|
-
{ key: 'graph', label: '
|
|
51
|
+
{ key: 'graph', label: 'GRAPH' },
|
|
52
52
|
{ key: 'feed', label: 'FEED' },
|
|
53
|
-
{ key: 'costs', label: '
|
|
53
|
+
{ key: 'costs', label: 'COSTS' },
|
|
54
54
|
].map((t) => (
|
|
55
55
|
<button
|
|
56
56
|
key={t.key}
|
|
@@ -101,11 +101,11 @@ export default function LineageGraphPage({ initialCosts }: LineageGraphPageProps
|
|
|
101
101
|
<button
|
|
102
102
|
type="button"
|
|
103
103
|
className="agent-btn"
|
|
104
|
-
aria-label="
|
|
104
|
+
aria-label="Reset graph view"
|
|
105
105
|
onClick={() => topologyRef.current?.resetView()}
|
|
106
106
|
style={resetButtonStyle}
|
|
107
107
|
>
|
|
108
|
-
⊙
|
|
108
|
+
⊙ Reset view
|
|
109
109
|
</button>
|
|
110
110
|
</div>
|
|
111
111
|
{hasSessionsLoaded ? (
|
|
@@ -180,27 +180,27 @@ export default function SessionDrawer({ sessionId, onClose }: SessionDrawerProps
|
|
|
180
180
|
{truncate(session.session_id, 60)}
|
|
181
181
|
</div>
|
|
182
182
|
<Grid>
|
|
183
|
-
<Row label="
|
|
183
|
+
<Row label="STATUS">
|
|
184
184
|
<span style={{ color: statusColor(session.status) }}>●</span>{' '}
|
|
185
185
|
{session.status ?? 'idle'}
|
|
186
186
|
</Row>
|
|
187
|
-
<Row label="
|
|
187
|
+
<Row label="TYPE">
|
|
188
188
|
{session.session_id.includes(':cron:') ? 'cron' : 'session'}
|
|
189
189
|
</Row>
|
|
190
190
|
<Row label="MODEL">{session.model ?? '-'}</Row>
|
|
191
|
-
<Row label="
|
|
191
|
+
<Row label="COST">{formatUsdOrDash(session.cost_usd)}</Row>
|
|
192
192
|
<Row label="TOKEN">
|
|
193
193
|
in: {formatTokens(session.tokens_in)} out: {formatTokens(session.tokens_out)}
|
|
194
194
|
</Row>
|
|
195
|
-
<Row label="
|
|
196
|
-
<Row label="
|
|
197
|
-
<Row label="
|
|
198
|
-
<Row label="
|
|
195
|
+
<Row label="STARTED">{formatTimeFromUnixSeconds(session.started_at)}</Row>
|
|
196
|
+
<Row label="UPDATED">{formatTimeFromUnixSeconds(session.updated_at)}</Row>
|
|
197
|
+
<Row label="ENDED">{formatTimeFromUnixSeconds(session.ended_at)}</Row>
|
|
198
|
+
<Row label="DURATION">{formatDuration(session.started_at, endedOrUpdated)}</Row>
|
|
199
199
|
<Row label="TASK">{truncate(session.task_preview ?? '-', 200)}</Row>
|
|
200
200
|
</Grid>
|
|
201
201
|
</Section>
|
|
202
202
|
|
|
203
|
-
<Section title="
|
|
203
|
+
<Section title="RECENT ACTIONS">
|
|
204
204
|
{events.length === 0 && <Muted>No events</Muted>}
|
|
205
205
|
{events.map((evt) => (
|
|
206
206
|
<div key={evt.id ?? `${evt.ts ?? 0}-${evt.type ?? 'event'}`} style={{ fontSize: 12 }}>
|
|
@@ -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
|
|
|
@@ -144,7 +224,7 @@ export default function SystemCockpit({
|
|
|
144
224
|
<span>{item.severity}</span>
|
|
145
225
|
</div>
|
|
146
226
|
<div className="ui-muted" style={{ marginTop: 4 }}>{item.details}</div>
|
|
147
|
-
{item.actionHref && <Link href={item.actionHref} className="ui-link" style={{ display: 'inline-block', marginTop: 6 }}>
|
|
227
|
+
{item.actionHref && <Link href={item.actionHref} className="ui-link" style={{ display: 'inline-block', marginTop: 6 }}>Open →</Link>}
|
|
148
228
|
</li>
|
|
149
229
|
))}
|
|
150
230
|
</ul>
|
|
@@ -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
|
+
}
|