@flame0510/project-aether 1.10.0 → 1.11.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 +1 -1
- package/app/agents/CostSection.tsx +2 -8
- package/app/agents/PageClient.tsx +7 -0
- package/app/agents/PanelRow.tsx +9 -0
- package/app/agents/ResourceSection.tsx +105 -0
- package/app/api/assistant/route.ts +2 -2
- package/app/api/containers/route.ts +18 -2
- package/app/api/costs/agent/route.ts +2 -3
- package/app/api/metrics/alerts/route.ts +47 -0
- package/app/api/metrics/containers/route.ts +54 -0
- package/app/api/metrics/route.ts +4 -6
- package/app/components/ui/Accordion.tsx +45 -0
- package/app/components/ui/TimeSeriesChart.tsx +20 -2
- package/app/components/ui/index.ts +1 -0
- package/app/containers/ContainersClient.tsx +48 -6
- package/app/globals.css +36 -0
- package/app/system/AgentCharts.tsx +138 -0
- package/app/system/AgentsSection.tsx +281 -0
- package/app/system/PageClient.tsx +7 -7
- package/app/system/RecentAlerts.tsx +72 -0
- package/app/system/SystemSkeleton.tsx +53 -1
- package/app/system/loading.tsx +5 -1
- package/daemon.js +195 -4
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/FRONTEND-ARCHITECTURE.md +12 -3
- package/docs/REV4A.md +4 -4
- package/docs/dev/API-REFERENCE.md +91 -17
- package/docs/dev/DATABASE.md +58 -2
- package/docs/rag/DATA-FRESHNESS.md +16 -1
- package/docs/rag/GLOSSARY.md +2 -2
- package/docs/rag/REV4A-OVERVIEW.md +7 -3
- package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
- package/lib/container-metrics.ts +340 -0
- package/lib/docker-socket-path.js +133 -0
- package/lib/docker-socket.ts +10 -99
- package/lib/docker-stats.js +284 -0
- package/lib/metrics-db.ts +15 -6
- package/lib/utils/format.ts +29 -3
- package/package.json +1 -1
- package/scripts/test-docker-stats.mjs +270 -0
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
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
21
|
- **System health** — runtime sessions, daemon heartbeat and errors, cron jobs and what the agents spent today, 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
|
+
- **System** — the host machine's CPU, memory, swap and storage, now and over 1h / 24h / 7d / 30d (sampled every 30 s, kept 30 days); what each agent and container consumes (CPU, memory, storage, processes — charts per container) and what Docker holds on disk; the machine's recent alerts
|
|
23
23
|
- **Costs** — what each agent spent, priced by its own OpenClaw with the prices Rev4a syncs (cache rates included, DeepSeek off-peak applied): by day, agent, model and session, with the calls that could not be priced
|
|
24
24
|
- **PWA-ready** — Install on phone for mobile monitoring
|
|
25
25
|
|
|
@@ -8,6 +8,7 @@ import Link from 'next/link';
|
|
|
8
8
|
import { useEffect, useRef, useState } from 'react';
|
|
9
9
|
import { apiFetch } from '@/lib/apiFetch';
|
|
10
10
|
import { formatCost } from '@/lib/utils/format';
|
|
11
|
+
import { PanelRow } from './PanelRow';
|
|
11
12
|
|
|
12
13
|
interface AgentCosts {
|
|
13
14
|
available: boolean;
|
|
@@ -31,8 +32,6 @@ function formatAge(ms: number): string {
|
|
|
31
32
|
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
const ROW = { display: 'flex', justifyContent: 'space-between', padding: '7px 14px', borderBottom: '1px solid var(--border)', fontSize: 12 } as const;
|
|
35
|
-
|
|
36
35
|
export default function CostSection({ container }: { container: string }) {
|
|
37
36
|
const [data, setData] = useState<AgentCosts | null>(null);
|
|
38
37
|
const [error, setError] = useState('');
|
|
@@ -81,12 +80,7 @@ export default function CostSection({ container }: { container: string }) {
|
|
|
81
80
|
|
|
82
81
|
return (
|
|
83
82
|
<div>
|
|
84
|
-
{rows.map(([label, value]) =>
|
|
85
|
-
<div key={label} style={ROW}>
|
|
86
|
-
<span style={{ color: '#888' }}>{label}</span>
|
|
87
|
-
<span style={{ color: 'var(--text)', textAlign: 'right', maxWidth: '60%', wordBreak: 'break-word' }}>{value}</span>
|
|
88
|
-
</div>
|
|
89
|
-
))}
|
|
83
|
+
{rows.map(([label, value]) => <PanelRow key={label} label={label} value={value} />)}
|
|
90
84
|
{(stale || data.error) && (
|
|
91
85
|
<div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
|
|
92
86
|
{data.error ?? 'Not read recently: a stopped agent keeps the figures of its last read.'}
|
|
@@ -9,6 +9,7 @@ import ImageDownloadBanner from './ImageDownloadBanner';
|
|
|
9
9
|
import ChannelManager from './ChannelManager';
|
|
10
10
|
import ModelSection from './ModelSection';
|
|
11
11
|
import CostSection from './CostSection';
|
|
12
|
+
import ResourceSection from './ResourceSection';
|
|
12
13
|
import BrowserAccessSection, { OpenControlUiButton } from './BrowserAccessSection';
|
|
13
14
|
import UpdateSection from './UpdateSection';
|
|
14
15
|
import { validatePortInput } from '@/lib/agent-ports';
|
|
@@ -1201,6 +1202,12 @@ function DetailPanel({ agent, busy, onDeleted, onChanged, onModelSaved, onAccess
|
|
|
1201
1202
|
<CostSection container={agent.name} />
|
|
1202
1203
|
</div>
|
|
1203
1204
|
|
|
1205
|
+
{/* Resources - what this agent's container consumes (history on the System page) */}
|
|
1206
|
+
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1207
|
+
<SectionHeader icon={<BracesIcon size={12} />} label="RESOURCES" />
|
|
1208
|
+
<ResourceSection container={agent.name} />
|
|
1209
|
+
</div>
|
|
1210
|
+
|
|
1204
1211
|
{/* Config - View openclaw.json */}
|
|
1205
1212
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1206
1213
|
<SectionHeader icon={<BracesIcon size={12} />} label="CONFIG" />
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** One label/value row of an agent-panel section (COSTS and RESOURCES): the label dim on the left, the value right. */
|
|
2
|
+
export function PanelRow({ label, value }: { label: string; value: string }) {
|
|
3
|
+
return (
|
|
4
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '7px 14px', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
|
|
5
|
+
<span style={{ color: '#888' }}>{label}</span>
|
|
6
|
+
<span style={{ color: 'var(--text)', textAlign: 'right', maxWidth: '60%', wordBreak: 'break-word' }}>{value}</span>
|
|
7
|
+
</div>
|
|
8
|
+
);
|
|
9
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resources section of the agent detail panel: what this agent's container consumes now and
|
|
5
|
+
* over the last 24 h (GET /api/metrics/containers, from metrics.db). The history charts are
|
|
6
|
+
* on the System page — one place, linked from here.
|
|
7
|
+
*/
|
|
8
|
+
import Link from 'next/link';
|
|
9
|
+
import { useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { apiFetch } from '@/lib/apiFetch';
|
|
11
|
+
import { formatMb, formatRate, formatSharePercent } from '@/lib/utils/format';
|
|
12
|
+
import { PanelRow } from './PanelRow';
|
|
13
|
+
|
|
14
|
+
interface Overview {
|
|
15
|
+
available: boolean;
|
|
16
|
+
interval_s: number;
|
|
17
|
+
age_s?: number | null;
|
|
18
|
+
containers?: {
|
|
19
|
+
container: string;
|
|
20
|
+
running: boolean;
|
|
21
|
+
now: { cpu_cores: number | null; cpu_percent: number | null; mem_mb: number | null; mem_percent: number | null; pids: number | null; net_rx_bps: number | null; net_tx_bps: number | null } | null;
|
|
22
|
+
range: { cpu_avg_percent: number | null; cpu_max_percent: number | null; mem_avg_mb: number | null; mem_max_mb: number | null };
|
|
23
|
+
storage: { volume_mb: number | null; layer_mb: number | null; total_mb: number | null };
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const note = (text: string) => <div style={{ padding: '12px 14px', fontSize: 12, color: '#888' }}>{text}</div>;
|
|
28
|
+
|
|
29
|
+
export default function ResourceSection({ container }: { container: string }) {
|
|
30
|
+
const [data, setData] = useState<Overview | null>(null);
|
|
31
|
+
const [error, setError] = useState('');
|
|
32
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const load = async () => {
|
|
36
|
+
abortRef.current?.abort();
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
abortRef.current = controller;
|
|
39
|
+
try {
|
|
40
|
+
const res = await apiFetch('/api/metrics/containers?range=24h', { signal: controller.signal });
|
|
41
|
+
const body = await res.json();
|
|
42
|
+
if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
|
|
43
|
+
setData(body as Overview);
|
|
44
|
+
setError('');
|
|
45
|
+
} catch (e: any) {
|
|
46
|
+
if (e?.name !== 'AbortError') setError(e?.message || 'Could not load the resources');
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
setData(null);
|
|
50
|
+
setError('');
|
|
51
|
+
load();
|
|
52
|
+
const timer = setInterval(load, 60_000);
|
|
53
|
+
return () => {
|
|
54
|
+
clearInterval(timer);
|
|
55
|
+
abortRef.current?.abort();
|
|
56
|
+
};
|
|
57
|
+
}, [container]);
|
|
58
|
+
|
|
59
|
+
if (error && !data) return <div style={{ padding: '12px 14px', fontSize: 12, color: 'var(--red)' }}>{error}</div>;
|
|
60
|
+
if (!data) return note('Loading…');
|
|
61
|
+
if (!data.available) return note(`No samples yet: the Rev4a daemon samples the containers every ${data.interval_s} s.`);
|
|
62
|
+
|
|
63
|
+
const row = data.containers?.find((c) => c.container === container);
|
|
64
|
+
if (!row) return note('Not sampled in the last 24 h: the agent was not running.');
|
|
65
|
+
// The same three-intervals rule as the System page: a daemon that stopped must not present
|
|
66
|
+
// hours-old figures as "now".
|
|
67
|
+
const stale = data.age_s != null && data.age_s > data.interval_s * 3;
|
|
68
|
+
const r = row.range;
|
|
69
|
+
const n = row.now;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div>
|
|
73
|
+
{error && (
|
|
74
|
+
<div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
|
|
75
|
+
Could not refresh ({error}): showing the last answer.
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
{stale ? (
|
|
79
|
+
<div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
|
|
80
|
+
Not collecting: the latest sample is {Math.round((data.age_s ?? 0) / 60)} min old. Check that the Rev4a daemon is running; the figures below are from when it last answered.
|
|
81
|
+
</div>
|
|
82
|
+
) : row.running && n ? (
|
|
83
|
+
<>
|
|
84
|
+
<PanelRow label="CPU now" value={`${formatSharePercent(n.cpu_percent)} of Docker's cores${n.cpu_cores !== null ? ` · ${n.cpu_cores.toFixed(3)} cores` : ''}`} />
|
|
85
|
+
<PanelRow label="Memory now" value={`${formatMb(n.mem_mb)}${n.mem_percent !== null ? ` · ${n.mem_percent}% of Docker's memory` : ''}`} />
|
|
86
|
+
<PanelRow label="Processes" value={String(n.pids ?? '—')} />
|
|
87
|
+
<PanelRow label="Network" value={`↓ ${formatRate(n.net_rx_bps)} ↑ ${formatRate(n.net_tx_bps)}`} />
|
|
88
|
+
</>
|
|
89
|
+
) : (
|
|
90
|
+
<div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
|
|
91
|
+
Stopped: the figures below are from when it last ran.
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
<PanelRow label="CPU · last 24 h" value={`avg ${formatSharePercent(r.cpu_avg_percent)} · peak ${formatSharePercent(r.cpu_max_percent)}`} />
|
|
95
|
+
<PanelRow label="Memory · last 24 h" value={`avg ${formatMb(r.mem_avg_mb)} · peak ${formatMb(r.mem_max_mb)}`} />
|
|
96
|
+
<PanelRow
|
|
97
|
+
label="Storage"
|
|
98
|
+
value={`${formatMb(row.storage.total_mb)}${row.storage.volume_mb !== null ? ` · volume ${formatMb(row.storage.volume_mb)}` : ''}${row.storage.layer_mb !== null ? ` · layer ${formatMb(row.storage.layer_mb)}` : ''}`}
|
|
99
|
+
/>
|
|
100
|
+
<div style={{ padding: '8px 14px', fontSize: 11 }}>
|
|
101
|
+
<Link href={`/system?agent=${encodeURIComponent(container)}`} className="ui-link">Charts on the System page →</Link>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -56,12 +56,12 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
|
|
|
56
56
|
title: 'System',
|
|
57
57
|
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.',
|
|
58
58
|
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'],
|
|
59
|
-
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.',
|
|
59
|
+
hints: 'Below the charts, "Agents and containers" shows what Docker holds on disk and one row per container (sorted by CPU) with its CPU share, memory, storage and processes; open a row for its own CPU, Memory and Storage charts, which follow the same range. "Recent alerts" lists what crossed its limit and when. 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.',
|
|
60
60
|
},
|
|
61
61
|
'/containers': {
|
|
62
62
|
title: 'Containers',
|
|
63
63
|
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.',
|
|
64
|
-
actions: ['View every container, running or stopped, with its details', 'Click "Terminal" on any container to open an interactive shell', 'Check each container\'s status, image, IP and ports (
|
|
64
|
+
actions: ['View every container, running or stopped, with its details', 'Click "Terminal" on any container to open an interactive shell', 'Check each container\'s status, image, IP and ports, and what a running one consumes now (CPU share, memory, processes), with a link to its charts on the System page'],
|
|
65
65
|
hints: 'Containers marked with an agentId belong to OpenClaw agents. Others are infrastructure containers like databases or reverse proxies.',
|
|
66
66
|
},
|
|
67
67
|
'/containers/terminal/[id]': {
|
|
@@ -2,6 +2,7 @@ import { type NextRequest } from "next/server";
|
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
3
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
4
4
|
import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
|
|
5
|
+
import { primaryName } from '@/lib/docker-stats';
|
|
5
6
|
|
|
6
7
|
export const dynamic = 'force-dynamic';
|
|
7
8
|
|
|
@@ -22,7 +23,9 @@ export async function GET(request: NextRequest) {
|
|
|
22
23
|
const ip = Object.values(networks)[0]?.IPAddress || null;
|
|
23
24
|
return {
|
|
24
25
|
id: c.Id?.slice(0, 12),
|
|
25
|
-
|
|
26
|
+
// The daemon's rule (the name with one leading slash), so the usage row and the
|
|
27
|
+
// /system?agent= link key on the same name the metrics store uses.
|
|
28
|
+
name: primaryName(c?.Names),
|
|
26
29
|
image: c.Image,
|
|
27
30
|
status: c.Status,
|
|
28
31
|
state: c.State,
|
|
@@ -33,7 +36,20 @@ export async function GET(request: NextRequest) {
|
|
|
33
36
|
created: c.Labels?.created || null,
|
|
34
37
|
};
|
|
35
38
|
} catch {
|
|
36
|
-
|
|
39
|
+
// Identity comes from the list: a failed inspect must neither drop the container nor
|
|
40
|
+
// give it a name the metrics store does not key on. Only the inspect-only fields are lost.
|
|
41
|
+
return {
|
|
42
|
+
id: c.Id?.slice(0, 12),
|
|
43
|
+
name: primaryName(c?.Names),
|
|
44
|
+
image: c.Image,
|
|
45
|
+
status: c.Status,
|
|
46
|
+
state: c.State,
|
|
47
|
+
ports: c.Ports?.map((p: any) => `${p.PublicPort || '?'}:${p.PrivatePort || '?'}`).join(', ') || '',
|
|
48
|
+
ip: null,
|
|
49
|
+
agentId: c.Labels?.AGENT_ID || null,
|
|
50
|
+
displayName: null,
|
|
51
|
+
created: c.Labels?.created || null,
|
|
52
|
+
};
|
|
37
53
|
}
|
|
38
54
|
}));
|
|
39
55
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
2
|
import { openCostsDb } from '@/lib/costs-db';
|
|
3
3
|
import { COLLECT_INTERVAL_S, lastDays, readCostSummary } from '@/lib/agent-costs';
|
|
4
|
+
import { CONTAINER_NAME_RE } from '@/lib/docker-stats';
|
|
4
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
6
|
|
|
6
7
|
export const dynamic = 'force-dynamic';
|
|
7
8
|
|
|
8
|
-
const CONTAINER_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
9
|
-
|
|
10
9
|
/**
|
|
11
10
|
* GET /api/costs/agent?container=<name> — one agent's spend for its panel: today, the
|
|
12
11
|
* last 7 and 30 UTC days, its top model and unpriced calls over 30 days, and when it
|
|
@@ -17,7 +16,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
17
16
|
if (denied) return denied as NextResponse;
|
|
18
17
|
|
|
19
18
|
const container = request.nextUrl.searchParams.get('container') ?? '';
|
|
20
|
-
if (!
|
|
19
|
+
if (!CONTAINER_NAME_RE.test(container)) return NextResponse.json({ error: 'Invalid container name' }, { status: 400 });
|
|
21
20
|
|
|
22
21
|
let db;
|
|
23
22
|
try {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import { openDb } from '@/lib/db';
|
|
3
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LIMIT = 10;
|
|
8
|
+
const MAX_LIMIT = 50;
|
|
9
|
+
|
|
10
|
+
interface AnomalyData { metric?: unknown; message?: unknown; threshold?: unknown; values?: unknown }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* GET /api/metrics/alerts?limit=10 — the machine's latest `system_anomaly` events (CPU, RAM
|
|
14
|
+
* or a disk over its threshold, written by daemon.js into events.db), newest first. Nothing
|
|
15
|
+
* else in `events`: the generic /api/events has no type filter.
|
|
16
|
+
*/
|
|
17
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
18
|
+
const denied = await requireAuthJWT(request);
|
|
19
|
+
if (denied) return denied as NextResponse;
|
|
20
|
+
|
|
21
|
+
const parsed = Number.parseInt(request.nextUrl.searchParams.get('limit') ?? String(DEFAULT_LIMIT), 10);
|
|
22
|
+
const limit = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), MAX_LIMIT) : DEFAULT_LIMIT;
|
|
23
|
+
try {
|
|
24
|
+
const db = openDb();
|
|
25
|
+
try {
|
|
26
|
+
const rows = db
|
|
27
|
+
.prepare("SELECT ts, data FROM events WHERE type = 'system_anomaly' ORDER BY ts DESC LIMIT ?")
|
|
28
|
+
.all(limit) as { ts: number; data: string | null }[];
|
|
29
|
+
const alerts = rows.map((row) => {
|
|
30
|
+
let data: AnomalyData = {};
|
|
31
|
+
try { data = JSON.parse(row.data ?? '{}') as AnomalyData; } catch { /* keep the bare event */ }
|
|
32
|
+
return {
|
|
33
|
+
ts: row.ts,
|
|
34
|
+
metric: typeof data.metric === 'string' ? data.metric : null,
|
|
35
|
+
message: typeof data.message === 'string' ? data.message : 'Machine anomaly',
|
|
36
|
+
threshold: typeof data.threshold === 'number' ? data.threshold : null,
|
|
37
|
+
values: Array.isArray(data.values) ? data.values.filter((v): v is number => typeof v === 'number') : [],
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
return NextResponse.json({ alerts });
|
|
41
|
+
} finally {
|
|
42
|
+
db.close();
|
|
43
|
+
}
|
|
44
|
+
} catch (e: unknown) {
|
|
45
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
|
+
import { METRIC_RANGES, openMetricsDb, type MetricRange } from '@/lib/metrics-db';
|
|
4
|
+
import { CONTAINER_INTERVAL_S, containerTablesReady, readContainerHistory, readContainerOverview } from '@/lib/container-metrics';
|
|
5
|
+
import { CONTAINER_NAME_RE } from '@/lib/docker-stats';
|
|
6
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/metrics/containers?range=1h|24h|7d|30d — what every container consumes (CPU,
|
|
12
|
+
* memory, processes, network, disk), now and as an average and peak over the range, plus
|
|
13
|
+
* what is not a container and the disk Docker holds.
|
|
14
|
+
*
|
|
15
|
+
* With `&container=<name>`: that container's history, bucketed like `GET /api/metrics`, for
|
|
16
|
+
* the charts of its accordion on the System page.
|
|
17
|
+
*
|
|
18
|
+
* Read from metrics.db (daemon.js samples every 60 s). `available: false` until the daemon
|
|
19
|
+
* has created the tables.
|
|
20
|
+
*/
|
|
21
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
22
|
+
const denied = await requireAuthJWT(request);
|
|
23
|
+
if (denied) return denied as NextResponse;
|
|
24
|
+
|
|
25
|
+
const params = request.nextUrl.searchParams;
|
|
26
|
+
const param = params.get('range') ?? '1h';
|
|
27
|
+
if (!Object.hasOwn(METRIC_RANGES, param)) {
|
|
28
|
+
return NextResponse.json({ error: `range must be one of ${Object.keys(METRIC_RANGES).join(', ')}` }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
const range = param as MetricRange;
|
|
31
|
+
const span = METRIC_RANGES[range];
|
|
32
|
+
const container = params.get('container');
|
|
33
|
+
if (container !== null && !CONTAINER_NAME_RE.test(container)) {
|
|
34
|
+
return NextResponse.json({ error: 'Invalid container name' }, { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
const base = { interval_s: CONTAINER_INTERVAL_S, range };
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const db = openMetricsDb();
|
|
40
|
+
if (!db) return NextResponse.json({ ...base, available: false });
|
|
41
|
+
try {
|
|
42
|
+
if (!containerTablesReady(db)) return NextResponse.json({ ...base, available: false });
|
|
43
|
+
return NextResponse.json({
|
|
44
|
+
...base,
|
|
45
|
+
available: true,
|
|
46
|
+
...(container ? readContainerHistory(db, container, span) : readContainerOverview(db, span, undefined, os.cpus().length)),
|
|
47
|
+
});
|
|
48
|
+
} finally {
|
|
49
|
+
db.close();
|
|
50
|
+
}
|
|
51
|
+
} catch (e: unknown) {
|
|
52
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
53
|
+
}
|
|
54
|
+
}
|
package/app/api/metrics/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os from 'os';
|
|
2
2
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
|
-
import { openMetricsDb } from '@/lib/metrics-db';
|
|
3
|
+
import { METRIC_RANGES as RANGES, openMetricsDb, type MetricRange as Range } from '@/lib/metrics-db';
|
|
4
4
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
5
|
|
|
6
6
|
export const dynamic = 'force-dynamic';
|
|
@@ -9,12 +9,10 @@ export const dynamic = 'force-dynamic';
|
|
|
9
9
|
const SAMPLE_INTERVAL_S = 30;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* History ranges
|
|
13
|
-
* shorter than two samples — a 30 s bucket on a 30 s timer that drifts a second would
|
|
14
|
-
*
|
|
12
|
+
* History ranges are in lib/metrics-db.ts; 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 leave
|
|
14
|
+
* empty buckets, drawn as gaps, where no sample is missing.
|
|
15
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
16
|
const POINTS = 120;
|
|
19
17
|
|
|
20
18
|
interface LatestRow {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useId, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
interface AccordionProps {
|
|
6
|
+
/** What the header says first: a name and its pills. */
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
/** The figures kept in the header whether it is open or closed. */
|
|
9
|
+
summary?: ReactNode;
|
|
10
|
+
/** A second, muted line under the header. */
|
|
11
|
+
detail?: ReactNode;
|
|
12
|
+
open: boolean;
|
|
13
|
+
onToggle: () => void;
|
|
14
|
+
/** DOM id of the whole accordion, for a link to land on it. */
|
|
15
|
+
id?: string;
|
|
16
|
+
/** The body, mounted only while open — a chart inside fetches nothing while closed. */
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A row that opens to show more: the header is a real `<button>` carrying the title, the
|
|
22
|
+
* summary figures and an optional detail line, so the answer to "what is this now" is on
|
|
23
|
+
* screen closed; the body holds what takes more room (charts). Controlled: the parent owns
|
|
24
|
+
* `open`, which lets a link or a fetch follow it. Square, flat, tokens only.
|
|
25
|
+
*/
|
|
26
|
+
export function Accordion({ title, summary, detail, open, onToggle, id, children }: AccordionProps) {
|
|
27
|
+
const bodyId = `${useId().replace(/:/g, '')}-body`;
|
|
28
|
+
return (
|
|
29
|
+
<section className={`ui-accordion${open ? ' ui-accordion--open' : ''}`} id={id}>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
className="ui-accordion__header"
|
|
33
|
+
aria-expanded={open}
|
|
34
|
+
aria-controls={open ? bodyId : undefined}
|
|
35
|
+
onClick={onToggle}
|
|
36
|
+
>
|
|
37
|
+
<span className="ui-accordion__caret" aria-hidden="true">▶</span>
|
|
38
|
+
<span className="ui-accordion__title">{title}</span>
|
|
39
|
+
{summary !== undefined && <span className="ui-accordion__summary">{summary}</span>}
|
|
40
|
+
{detail !== undefined && <span className="ui-accordion__detail">{detail}</span>}
|
|
41
|
+
</button>
|
|
42
|
+
{open && <div className="ui-accordion__body" id={bodyId}>{children}</div>}
|
|
43
|
+
</section>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -20,7 +20,12 @@ interface TimeSeriesChartProps {
|
|
|
20
20
|
to: number;
|
|
21
21
|
/** Seconds per point; two points further apart than 1.5 buckets are not joined. */
|
|
22
22
|
bucket: number;
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The top of the y axis. A number fixes it (100 for a percentage); `'auto'` takes a rounded
|
|
25
|
+
* ceiling just above the highest value or peak plotted — for a series that is a small
|
|
26
|
+
* fraction of its natural scale, like one agent's share of the machine.
|
|
27
|
+
*/
|
|
28
|
+
yMax?: number | 'auto';
|
|
24
29
|
unit?: string;
|
|
25
30
|
/** Formats a value (grid labels, end label, tooltip, table) instead of `<rounded><unit>` — e.g. dollars. */
|
|
26
31
|
formatValue?: (v: number) => string;
|
|
@@ -30,6 +35,17 @@ interface TimeSeriesChartProps {
|
|
|
30
35
|
formatTime: (ts: number) => string;
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
/** A rounded ceiling (1, 2 or 5 times a power of ten) just above the highest value or peak. */
|
|
39
|
+
function autoYMax(points: TimeSeriesPoint[]): number {
|
|
40
|
+
let top = 0;
|
|
41
|
+
for (const p of points) top = Math.max(top, p.value ?? 0, p.peak ?? 0);
|
|
42
|
+
if (!(top > 0)) return 1;
|
|
43
|
+
const target = top * 1.1;
|
|
44
|
+
const base = 10 ** Math.floor(Math.log10(target));
|
|
45
|
+
const m = target / base;
|
|
46
|
+
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * base;
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
const PAD = { top: 12, right: 44, bottom: 22, left: 34 };
|
|
34
50
|
/** Width of one character of the 10 px monospace labels, for sizing the margins. */
|
|
35
51
|
const CHAR_W = 6.2;
|
|
@@ -46,7 +62,7 @@ export function TimeSeriesChart({
|
|
|
46
62
|
from,
|
|
47
63
|
to,
|
|
48
64
|
bucket,
|
|
49
|
-
yMax = 100,
|
|
65
|
+
yMax: yMaxProp = 100,
|
|
50
66
|
unit = '%',
|
|
51
67
|
formatValue,
|
|
52
68
|
height = 160,
|
|
@@ -66,6 +82,8 @@ export function TimeSeriesChart({
|
|
|
66
82
|
return () => ro.disconnect();
|
|
67
83
|
}, []);
|
|
68
84
|
|
|
85
|
+
const yMax = yMaxProp === 'auto' ? autoYMax(points) : yMaxProp;
|
|
86
|
+
|
|
69
87
|
// With a custom format (`$0.0054`) the margins grow to fit the labels instead of cutting
|
|
70
88
|
// them; percentage charts keep the fixed margins.
|
|
71
89
|
const valueText = (v: number) => (formatValue ? formatValue(v) : `${Math.round(v * 10) / 10}${unit}`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
+
import { formatMb, formatSharePercent } from '@/lib/utils/format';
|
|
5
6
|
import { Button, Pill } from '../components/ui';
|
|
6
7
|
|
|
7
8
|
interface ContainerInfo {
|
|
@@ -16,17 +17,40 @@ interface ContainerInfo {
|
|
|
16
17
|
displayName?: string | null;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/** What a running container consumes now, from GET /api/metrics/containers; absent when the daemon has not sampled it. */
|
|
21
|
+
interface Usage { cpu: number | null; mem: number | null; pids: number | null }
|
|
22
|
+
|
|
19
23
|
export default function ContainersClient() {
|
|
20
24
|
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
|
25
|
+
const [usage, setUsage] = useState<Record<string, Usage>>({});
|
|
21
26
|
const [loading, setLoading] = useState(true);
|
|
22
27
|
const [error, setError] = useState('');
|
|
28
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
23
29
|
|
|
24
30
|
async function load(signal?: AbortSignal) {
|
|
25
31
|
try {
|
|
26
|
-
const res = await
|
|
32
|
+
const [res, usageRes] = await Promise.all([
|
|
33
|
+
fetch('/api/containers', { signal }),
|
|
34
|
+
// The figures are a bonus: a daemon that has not sampled yet must not break the list.
|
|
35
|
+
fetch('/api/metrics/containers?range=1h', { signal }).catch(() => null),
|
|
36
|
+
]);
|
|
27
37
|
if (!res.ok) throw new Error(`Error ${res.status}`);
|
|
28
38
|
const data = await res.json();
|
|
29
39
|
if (!signal?.aborted) setContainers(Array.isArray(data) ? data : []);
|
|
40
|
+
// The figures are shown only while they are current: a failed request, tables not created
|
|
41
|
+
// yet, or a daemon that stopped sampling (newest sample older than three intervals) clears
|
|
42
|
+
// them — a live container must not keep its last CPU as if it were now.
|
|
43
|
+
const u = usageRes?.ok ? await usageRes.json().catch(() => null) : null;
|
|
44
|
+
if (!signal?.aborted) {
|
|
45
|
+
const current = u?.available && typeof u.age_s === 'number' && u.age_s <= u.interval_s * 3;
|
|
46
|
+
const next: Record<string, Usage> = {};
|
|
47
|
+
if (current) {
|
|
48
|
+
for (const row of u.containers ?? []) {
|
|
49
|
+
if (row.running && row.now) next[row.container] = { cpu: row.now.cpu_percent, mem: row.now.mem_mb, pids: row.now.pids };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
setUsage(next);
|
|
53
|
+
}
|
|
30
54
|
} catch(e: any) {
|
|
31
55
|
if (e?.name === 'AbortError') return;
|
|
32
56
|
if (!signal?.aborted) setError(e.message);
|
|
@@ -35,11 +59,19 @@ export default function ContainersClient() {
|
|
|
35
59
|
}
|
|
36
60
|
}
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
// One request at a time: a manual refresh or a new poll aborts the request before it, so a
|
|
63
|
+
// slow answer can never overwrite a newer one.
|
|
64
|
+
function refresh() {
|
|
65
|
+
abortRef.current?.abort();
|
|
39
66
|
const controller = new AbortController();
|
|
67
|
+
abortRef.current = controller;
|
|
40
68
|
load(controller.signal);
|
|
41
|
-
|
|
42
|
-
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
refresh();
|
|
73
|
+
const i = setInterval(refresh, 15000);
|
|
74
|
+
return () => { abortRef.current?.abort(); clearInterval(i); };
|
|
43
75
|
}, []);
|
|
44
76
|
|
|
45
77
|
return (
|
|
@@ -51,7 +83,7 @@ export default function ContainersClient() {
|
|
|
51
83
|
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
|
|
52
84
|
{containers.length} container · <span style={{ fontFamily: 'var(--font-mono-stack)', fontSize: 11 }}>{new Date().toLocaleTimeString('it-IT')}</span>
|
|
53
85
|
</span>
|
|
54
|
-
<Button variant="ghost" size="sm" onClick={
|
|
86
|
+
<Button variant="ghost" size="sm" onClick={refresh} aria-label="Refresh containers">↻</Button>
|
|
55
87
|
</div>
|
|
56
88
|
</div>
|
|
57
89
|
|
|
@@ -113,6 +145,16 @@ export default function ContainersClient() {
|
|
|
113
145
|
{c.ports && <span style={{ fontFamily: 'var(--font-mono-stack)', color: '#888' }}>{c.ports}</span>}
|
|
114
146
|
</div>
|
|
115
147
|
|
|
148
|
+
{/* What it consumes now — running containers the daemon has sampled */}
|
|
149
|
+
{c.state === 'running' && usage[c.name] && (
|
|
150
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-dim)', fontFamily: 'var(--font-mono-stack)' }}>
|
|
151
|
+
<span>CPU {formatSharePercent(usage[c.name].cpu)}</span>
|
|
152
|
+
<span>Memory {formatMb(usage[c.name].mem)}</span>
|
|
153
|
+
<span>PIDs {usage[c.name].pids ?? '—'}</span>
|
|
154
|
+
<Link href={`/system?agent=${encodeURIComponent(c.name)}`} className="ui-link">Charts →</Link>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
116
158
|
{/* Agent badge — only for Rev4a-managed agents */}
|
|
117
159
|
{(c.displayName || c.agentId) && (
|
|
118
160
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
package/app/globals.css
CHANGED
|
@@ -677,6 +677,42 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
|
|
|
677
677
|
.terminal-page__screen { flex: 1; min-height: 0; padding: 8px 0 0 12px; overflow: hidden; }
|
|
678
678
|
.terminal-page__screen .xterm { height: 100%; }
|
|
679
679
|
|
|
680
|
+
/* Accordion: a header button carrying title, summary figures and a detail line; the body opens under it. */
|
|
681
|
+
.ui-accordion { border: 1px solid var(--border); background: var(--bg2); border-radius: 0; }
|
|
682
|
+
.ui-accordion__header {
|
|
683
|
+
display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; column-gap: 10px; row-gap: 4px; align-items: center;
|
|
684
|
+
width: 100%; box-sizing: border-box; padding: 12px 16px; background: none; border: 0; border-radius: 0;
|
|
685
|
+
color: var(--text); font: inherit; text-align: left; cursor: pointer;
|
|
686
|
+
}
|
|
687
|
+
.ui-accordion__header:hover { background: rgba(255, 255, 255, 0.02); }
|
|
688
|
+
.ui-accordion__header:focus-visible { outline: 1px solid var(--violet-dim); outline-offset: -1px; }
|
|
689
|
+
.ui-accordion__caret { font-size: 10px; color: var(--text-dim); transition: transform 0.15s; }
|
|
690
|
+
.ui-accordion--open .ui-accordion__caret { transform: rotate(90deg); }
|
|
691
|
+
.ui-accordion__title { min-width: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
|
|
692
|
+
.ui-accordion__summary { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 20px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
693
|
+
.ui-accordion__detail { grid-column: 2 / -1; font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
|
694
|
+
.ui-accordion__body { border-top: 1px solid var(--border); padding: 16px; }
|
|
695
|
+
@media (max-width: 640px) {
|
|
696
|
+
.ui-accordion__header { grid-template-columns: 14px minmax(0, 1fr); }
|
|
697
|
+
.ui-accordion__summary { grid-column: 2 / -1; justify-content: flex-start; }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/* The three machine charts (and a storage chart per extra disk) fill the grid: an odd last one spans both columns instead of leaving a hole. */
|
|
701
|
+
.system-charts > :last-child:nth-child(odd) { grid-column: 1 / -1; }
|
|
702
|
+
/* A section of the page under the cards and charts: the accent eyebrow (the page header's own), set apart by a rule and room above, so it does not run into the panel before it. */
|
|
703
|
+
.system-section-heading { margin-top: 14px; padding-top: 20px; border-top: 1px solid var(--border); }
|
|
704
|
+
.system-section-heading .ui-eyebrow { margin-bottom: 6px; }
|
|
705
|
+
.system-agent-charts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
706
|
+
@media (max-width: 1199px) {
|
|
707
|
+
.system-agent-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
708
|
+
.system-agent-charts > :last-child:nth-child(odd) { grid-column: 1 / -1; }
|
|
709
|
+
}
|
|
710
|
+
@media (max-width: 991px) { .system-agent-charts { grid-template-columns: minmax(0, 1fr); } }
|
|
711
|
+
.system-docker-storage { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
|
712
|
+
@media (max-width: 640px) { .system-docker-storage { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
713
|
+
.system-alert { display: grid; grid-template-columns: 110px 64px minmax(0, 1fr); gap: 10px; align-items: baseline; font-size: 12px; padding: 6px 0; border-top: 1px solid var(--border); }
|
|
714
|
+
.system-alert:first-of-type { border-top: 0; }
|
|
715
|
+
@media (max-width: 640px) { .system-alert { grid-template-columns: 1fr; gap: 2px; } }
|
|
680
716
|
/* System page: three cards, then one chart per metric; one column below lg (992px). */
|
|
681
717
|
.system-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
682
718
|
.system-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|