@flame0510/project-aether 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/agent-templates/atlas/HEARTBEAT.md +1 -1
  4. package/app/agents/ImageDownloadBanner.tsx +171 -37
  5. package/app/api/agents/download-image/route.ts +29 -5
  6. package/app/api/agents/image-status/route.ts +17 -2
  7. package/app/api/assistant/route.ts +21 -5
  8. package/app/api/auth/login/route.ts +2 -2
  9. package/app/api/metrics/route.ts +126 -23
  10. package/app/api/setup/agent-image/route.ts +6 -4
  11. package/app/api/system-health/route.ts +25 -21
  12. package/app/components/DashboardToolbar.tsx +2 -2
  13. package/app/components/LineageGraphPage.tsx +4 -4
  14. package/app/components/SessionDrawer.tsx +8 -8
  15. package/app/components/Sidebar.tsx +10 -0
  16. package/app/components/Skeleton.tsx +4 -1
  17. package/app/components/SystemCockpit.tsx +83 -3
  18. package/app/components/ui/Meter.tsx +34 -0
  19. package/app/components/ui/TimeSeriesChart.tsx +226 -0
  20. package/app/components/ui/index.ts +2 -0
  21. package/app/globals.css +42 -1
  22. package/app/setup/PageClient.tsx +1 -1
  23. package/app/system/PageClient.tsx +263 -0
  24. package/app/system/SystemSkeleton.tsx +115 -0
  25. package/app/system/loading.tsx +13 -0
  26. package/app/system/page.tsx +5 -0
  27. package/bin/postinstall.js +5 -1
  28. package/daemon.js +274 -214
  29. package/docs/ARCHITECTURE.md +65 -34
  30. package/docs/CONTAINER-TERMINAL.md +17 -8
  31. package/docs/DESIGN-SYSTEM.md +22 -12
  32. package/docs/FRONTEND-ARCHITECTURE.md +8 -4
  33. package/docs/REV4A.md +37 -86
  34. package/docs/dev/API-REFERENCE.md +102 -19
  35. package/docs/dev/DATABASE.md +79 -28
  36. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
  37. package/docs/rag/DATA-FRESHNESS.md +31 -15
  38. package/docs/rag/GLOSSARY.md +8 -5
  39. package/docs/rag/REV4A-OVERVIEW.md +14 -7
  40. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -3
  41. package/lib/agent-images.ts +43 -14
  42. package/lib/buildAgentImage.ts +142 -6
  43. package/lib/db-bootstrap.mjs +0 -11
  44. package/lib/metrics-db.ts +48 -0
  45. package/lib/patterns/sessionPresentation.ts +4 -2
  46. package/lib/rev4a-auth.d.ts +1 -0
  47. package/lib/rev4a-auth.js +18 -2
  48. package/next.config.mjs +9 -1
  49. package/package.json +2 -2
  50. package/scripts/backup.sh +48 -54
  51. package/scripts/check-language.mjs +21 -3
  52. package/scripts/restore.sh +77 -59
@@ -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
  }
@@ -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: ctrl.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
- cancel() { /* downloadAgentImage stops the docker process through the signal */ },
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
- 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 (soglia ${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 fa (soglia ${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
 
@@ -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)}. Include OpenRouter e altri provider pay-per-use, non i piani fissi.`,
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">AGENTI</span>
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">PERIODO</span>
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: 'GRAFO' },
51
+ { key: 'graph', label: 'GRAPH' },
52
52
  { key: 'feed', label: 'FEED' },
53
- { key: 'costs', label: 'COSTI' },
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="Reimposta vista grafo"
104
+ aria-label="Reset graph view"
105
105
  onClick={() => topologyRef.current?.resetView()}
106
106
  style={resetButtonStyle}
107
107
  >
108
- ⊙ Reimposta vista
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="STATO">
183
+ <Row label="STATUS">
184
184
  <span style={{ color: statusColor(session.status) }}>●</span>{' '}
185
185
  {session.status ?? 'idle'}
186
186
  </Row>
187
- <Row label="TIPO">
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="COSTO">{formatUsdOrDash(session.cost_usd)}</Row>
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="INIZIO">{formatTimeFromUnixSeconds(session.started_at)}</Row>
196
- <Row label="AGGIORN.">{formatTimeFromUnixSeconds(session.updated_at)}</Row>
197
- <Row label="FINE">{formatTimeFromUnixSeconds(session.ended_at)}</Row>
198
- <Row label="DURATA">{formatDuration(session.started_at, endedOrUpdated)}</Row>
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="AZIONI RECENTI">
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 <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 */
@@ -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 }}>Apri →</Link>}
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
+ }