@flame0510/project-aether 1.9.1 → 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 +3 -3
- package/app/agents/CostSection.tsx +94 -0
- package/app/agents/PageClient.tsx +14 -0
- package/app/agents/PanelRow.tsx +9 -0
- package/app/agents/ResourceSection.tsx +105 -0
- package/app/api/assistant/route.ts +10 -3
- package/app/api/containers/route.ts +18 -2
- package/app/api/costs/agent/route.ts +55 -0
- package/app/api/costs/route.ts +19 -77
- package/app/api/costs/usage/route.ts +65 -0
- package/app/api/gateway/sync.ts +18 -4
- 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/api/stats/route.ts +2 -3
- package/app/api/stats-since/route.ts +1 -1
- package/app/api/stream/route.ts +3 -5
- package/app/api/system-health/route.ts +33 -32
- package/app/components/CostBreakdown.tsx +1 -1
- package/app/components/Sidebar.tsx +10 -0
- package/app/components/SystemCockpit.tsx +1 -1
- package/app/components/ui/Accordion.tsx +45 -0
- package/app/components/ui/TimeSeriesChart.tsx +47 -11
- package/app/components/ui/index.ts +1 -0
- package/app/containers/ContainersClient.tsx +48 -6
- package/app/costs/CostsSkeleton.tsx +88 -0
- package/app/costs/PageClient.tsx +366 -0
- package/app/costs/loading.tsx +13 -0
- package/app/costs/page.tsx +5 -0
- package/app/globals.css +42 -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 +646 -41
- package/docs/ARCHITECTURE.md +72 -31
- package/docs/DESIGN-SYSTEM.md +2 -2
- package/docs/FRONTEND-ARCHITECTURE.md +14 -3
- package/docs/REV4A.md +6 -5
- package/docs/dev/API-REFERENCE.md +208 -38
- package/docs/dev/DATABASE.md +176 -20
- package/docs/dev/GATEWAY.md +53 -9
- package/docs/rag/DATA-FRESHNESS.md +34 -7
- package/docs/rag/GLOSSARY.md +8 -5
- package/docs/rag/REV4A-OVERVIEW.md +10 -3
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -3
- package/instrumentation.ts +11 -0
- package/lib/agent-costs.ts +172 -0
- package/lib/container-metrics.ts +340 -0
- package/lib/cost-reconciliation.ts +78 -0
- package/lib/costs-db.ts +31 -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/model-pricing.ts +123 -3
- package/lib/price-schedule-sync.ts +133 -0
- package/lib/utils/format.ts +38 -0
- package/model-pricing.json +269 -121
- package/package.json +1 -1
- package/scripts/refresh-model-pricing.mjs +16 -4
- package/scripts/test-docker-stats.mjs +270 -0
- package/lib/billing.ts +0 -100
|
@@ -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 {
|
package/app/api/stats/route.ts
CHANGED
|
@@ -9,9 +9,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
9
9
|
try {
|
|
10
10
|
const db = openDb();
|
|
11
11
|
const now = new Date();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
);
|
|
12
|
+
// sessions.started_at is in milliseconds (daemon.js).
|
|
13
|
+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
15
14
|
const total = db
|
|
16
15
|
.prepare(
|
|
17
16
|
`SELECT COALESCE(SUM(cost_usd),0) as total, COALESCE(SUM(tokens_in),0) as total_in,
|
|
@@ -11,7 +11,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
11
11
|
const db = openDb();
|
|
12
12
|
const row = db
|
|
13
13
|
.prepare('SELECT COALESCE(SUM(cost_usd),0) as total FROM sessions WHERE started_at >= ?')
|
|
14
|
-
.get(
|
|
14
|
+
.get(ts) as { total: number } | undefined; // both in milliseconds
|
|
15
15
|
db.close();
|
|
16
16
|
return NextResponse.json({ total: row?.total ?? 0 });
|
|
17
17
|
} catch (e: unknown) {
|
package/app/api/stream/route.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type NextRequest } from 'next/server';
|
|
|
2
2
|
import { DB_PATH } from '@/lib/db';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
4
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
|
+
import { dashboardCosts } from '@/lib/agent-costs';
|
|
5
6
|
|
|
6
7
|
export const dynamic = 'force-dynamic';
|
|
7
8
|
|
|
@@ -32,12 +33,9 @@ export async function GET(request: NextRequest): Promise<Response> {
|
|
|
32
33
|
LEFT JOIN lineage l ON s.session_id = l.child_id
|
|
33
34
|
ORDER BY s.started_at DESC LIMIT 100`)
|
|
34
35
|
.all();
|
|
35
|
-
const costs = db
|
|
36
|
-
.prepare('SELECT SUM(cost_usd) AS today FROM sessions WHERE started_at >= ?')
|
|
37
|
-
.get(Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)) as
|
|
38
|
-
| { today: number | null }
|
|
39
|
-
| undefined;
|
|
40
36
|
db.close();
|
|
37
|
+
// What the agents' OpenClaw priced today (UTC day), like GET /api/costs.
|
|
38
|
+
const costs = dashboardCosts();
|
|
41
39
|
|
|
42
40
|
if (newEvents.length > 0) {
|
|
43
41
|
lastEventId = newEvents[newEvents.length - 1].id;
|
|
@@ -2,13 +2,15 @@ import { NextResponse, type NextRequest } from 'next/server';
|
|
|
2
2
|
import { openDb } from '@/lib/db';
|
|
3
3
|
import { latestMetricSample } from '@/lib/metrics-db';
|
|
4
4
|
import { maybeSendAlert } from '@/lib/alerts';
|
|
5
|
-
import {
|
|
5
|
+
import { dashboardCosts } from '@/lib/agent-costs';
|
|
6
6
|
import { listOpenClawCronJobs } from '@/lib/openclaw-cron';
|
|
7
|
-
import type { ModelCost } from '@/lib/types';
|
|
8
7
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
9
8
|
|
|
10
9
|
export const dynamic = 'force-dynamic';
|
|
11
10
|
|
|
11
|
+
/** A day above this (US dollars, UTC day) raises the cost check to a warning. */
|
|
12
|
+
const HIGH_DAILY_SPEND_USD = 10;
|
|
13
|
+
|
|
12
14
|
type Health = 'ok' | 'warning' | 'error';
|
|
13
15
|
type Severity = 'info' | 'warning' | 'critical';
|
|
14
16
|
|
|
@@ -146,7 +148,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
146
148
|
? [columnExists(db, 'events', 'type') ? 'type' : null, columnExists(db, 'events', 'event') ? 'event' : null].filter(Boolean).join(", ''), COALESCE(")
|
|
147
149
|
: '';
|
|
148
150
|
const eventErrorCount = eventExpr
|
|
149
|
-
? (db.prepare(`SELECT COUNT(*) AS total FROM events WHERE LOWER(COALESCE(${eventExpr}, '')) LIKE '%error%' AND ts >= ?`).get(
|
|
151
|
+
? (db.prepare(`SELECT COUNT(*) AS total FROM events WHERE LOWER(COALESCE(${eventExpr}, '')) LIKE '%error%' AND ts >= ?`).get(Date.now() - 86_400_000) as { total: number }).total // events.ts is in milliseconds
|
|
150
152
|
: 0;
|
|
151
153
|
checks.push({
|
|
152
154
|
id: 'runtime.errors24h',
|
|
@@ -168,54 +170,53 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
168
170
|
});
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
173
|
+
db.close();
|
|
174
|
+
} catch (error) {
|
|
175
|
+
checks.push({ id: 'runtime.db', label: 'Rev4a DB', health: 'error', value: 'error', details: (error as Error).message, source: 'runtime' });
|
|
176
|
+
recommendations.push({ id: 'runtime.db-error', severity: 'critical', source: 'runtime', title: 'Rev4a DB unreadable', details: (error as Error).message, dismissible: false, createdAt });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// What the agents spent today (UTC day), as their own OpenClaw priced it (costs.db).
|
|
180
|
+
const spend = dashboardCosts();
|
|
181
|
+
if (spend?.error) {
|
|
182
|
+
checks.push({ id: 'cost.usageBasedToday', label: 'Spent today', health: 'warning', value: 'error', details: `costs.db unreadable: ${spend.error}`, source: 'cost' });
|
|
183
|
+
} else {
|
|
184
|
+
const today = spend?.today ?? 0;
|
|
185
|
+
const missing = spend?.missingToday ?? 0;
|
|
183
186
|
checks.push({
|
|
184
187
|
id: 'cost.usageBasedToday',
|
|
185
|
-
label: '
|
|
186
|
-
health:
|
|
187
|
-
value: `$${
|
|
188
|
-
details:
|
|
188
|
+
label: 'Spent today',
|
|
189
|
+
health: today > HIGH_DAILY_SPEND_USD || missing > 0 ? 'warning' : 'ok',
|
|
190
|
+
value: `$${today.toFixed(2)}`,
|
|
191
|
+
details: spend
|
|
192
|
+
? `Priced by the agents' OpenClaw, UTC day${missing ? `; ${missing} call(s) without a price` : ''}`
|
|
193
|
+
: 'No figures yet: the daemon reads the agents every 5 min',
|
|
189
194
|
source: 'cost',
|
|
190
195
|
});
|
|
191
|
-
if (
|
|
196
|
+
if (today > HIGH_DAILY_SPEND_USD) {
|
|
192
197
|
recommendations.push({
|
|
193
198
|
id: 'cost.review-usage-based',
|
|
194
199
|
severity: 'warning',
|
|
195
200
|
source: 'cost',
|
|
196
|
-
title: 'High
|
|
197
|
-
details: `
|
|
198
|
-
actionHref: '/
|
|
201
|
+
title: 'High spend today',
|
|
202
|
+
details: `The agents have spent $${today.toFixed(2)} today (UTC).`,
|
|
203
|
+
actionHref: '/costs',
|
|
199
204
|
dismissible: true,
|
|
200
205
|
createdAt,
|
|
201
206
|
});
|
|
202
207
|
}
|
|
203
|
-
if (
|
|
208
|
+
if (missing > 0) {
|
|
204
209
|
recommendations.push({
|
|
205
|
-
id: 'cost.
|
|
210
|
+
id: 'cost.unpriced-calls',
|
|
206
211
|
severity: 'warning',
|
|
207
212
|
source: 'cost',
|
|
208
|
-
title: '
|
|
209
|
-
details: `${
|
|
210
|
-
actionHref: '/
|
|
213
|
+
title: 'Calls without a price',
|
|
214
|
+
details: `${missing} model call(s) today could not be priced: their model has no price in Rev4a, or a dynamic one (OpenRouter Auto).`,
|
|
215
|
+
actionHref: '/costs',
|
|
211
216
|
dismissible: true,
|
|
212
217
|
createdAt,
|
|
213
218
|
});
|
|
214
219
|
}
|
|
215
|
-
db.close();
|
|
216
|
-
} catch (error) {
|
|
217
|
-
checks.push({ id: 'runtime.db', label: 'Rev4a DB', health: 'error', value: 'error', details: (error as Error).message, source: 'runtime' });
|
|
218
|
-
recommendations.push({ id: 'runtime.db-error', severity: 'critical', source: 'runtime', title: 'Rev4a DB unreadable', details: (error as Error).message, dismissible: false, createdAt });
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
try {
|
|
@@ -12,7 +12,7 @@ interface CostBreakdownProps {
|
|
|
12
12
|
export default function CostBreakdown({ byModel, loading = false }: CostBreakdownProps) {
|
|
13
13
|
return (
|
|
14
14
|
<article className="panel cost-panel">
|
|
15
|
-
<h2>
|
|
15
|
+
<h2>Spent by Model (Today, UTC)</h2>
|
|
16
16
|
<div>
|
|
17
17
|
{loading ? (
|
|
18
18
|
<div style={{ padding: 12 }}>
|
|
@@ -59,6 +59,16 @@ const NAV: NavItem[] = [
|
|
|
59
59
|
</svg>
|
|
60
60
|
),
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
href: '/costs',
|
|
64
|
+
label: 'Costs',
|
|
65
|
+
icon: (
|
|
66
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
67
|
+
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.3" />
|
|
68
|
+
<path d="M10 5.8C9.6 5.3 8.9 5 8 5c-1.2 0-2 .6-2 1.5S6.8 7.8 8 8s2 .6 2 1.5S9.2 11 8 11c-.9 0-1.6-.3-2-.8M8 3.8V5M8 11v1.2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
69
|
+
</svg>
|
|
70
|
+
),
|
|
71
|
+
},
|
|
62
72
|
{
|
|
63
73
|
href: '/system',
|
|
64
74
|
label: 'System',
|
|
@@ -201,7 +201,7 @@ export default function SystemCockpit({
|
|
|
201
201
|
<Link href="/system" className="ui-link">Open System →</Link>
|
|
202
202
|
</div>
|
|
203
203
|
</Surface>
|
|
204
|
-
<Metric title="
|
|
204
|
+
<Metric title="Spent today" value={String(costCheck?.value ?? fmtMoney(costs.today))} subtitle={costCheck?.details ?? 'Priced by the agents\' OpenClaw, UTC day'} tone="accent" loading={systemLoading} />
|
|
205
205
|
</div>
|
|
206
206
|
|
|
207
207
|
<div className="cockpit__main">
|
|
@@ -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,15 +20,35 @@ 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;
|
|
30
|
+
/** Formats a value (grid labels, end label, tooltip, table) instead of `<rounded><unit>` — e.g. dollars. */
|
|
31
|
+
formatValue?: (v: number) => string;
|
|
25
32
|
height?: number;
|
|
26
33
|
/** While a new range loads the previous render stays, dimmed. */
|
|
27
34
|
stale?: boolean;
|
|
28
35
|
formatTime: (ts: number) => string;
|
|
29
36
|
}
|
|
30
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
|
+
|
|
31
49
|
const PAD = { top: 12, right: 44, bottom: 22, left: 34 };
|
|
50
|
+
/** Width of one character of the 10 px monospace labels, for sizing the margins. */
|
|
51
|
+
const CHAR_W = 6.2;
|
|
32
52
|
|
|
33
53
|
/**
|
|
34
54
|
* One series over time: a 2px line for the value, a 10% wash up to the peak, a hairline
|
|
@@ -42,8 +62,9 @@ export function TimeSeriesChart({
|
|
|
42
62
|
from,
|
|
43
63
|
to,
|
|
44
64
|
bucket,
|
|
45
|
-
yMax = 100,
|
|
65
|
+
yMax: yMaxProp = 100,
|
|
46
66
|
unit = '%',
|
|
67
|
+
formatValue,
|
|
47
68
|
height = 160,
|
|
48
69
|
stale = false,
|
|
49
70
|
formatTime,
|
|
@@ -61,10 +82,24 @@ export function TimeSeriesChart({
|
|
|
61
82
|
return () => ro.disconnect();
|
|
62
83
|
}, []);
|
|
63
84
|
|
|
64
|
-
const
|
|
85
|
+
const yMax = yMaxProp === 'auto' ? autoYMax(points) : yMaxProp;
|
|
86
|
+
|
|
87
|
+
// With a custom format (`$0.0054`) the margins grow to fit the labels instead of cutting
|
|
88
|
+
// them; percentage charts keep the fixed margins.
|
|
89
|
+
const valueText = (v: number) => (formatValue ? formatValue(v) : `${Math.round(v * 10) / 10}${unit}`);
|
|
90
|
+
const tickText = (v: number) => (formatValue ? formatValue(v) : `${Math.round(v)}${unit}`);
|
|
91
|
+
const tickValues = [0, 50, 100].map((v) => (v / 100) * yMax);
|
|
92
|
+
const lastValue = [...points].reverse().find((p) => p.value !== null)?.value ?? null;
|
|
93
|
+
const padLeft = formatValue
|
|
94
|
+
? Math.max(PAD.left, Math.ceil(Math.max(...tickValues.map((v) => tickText(v).length)) * CHAR_W) + 8)
|
|
95
|
+
: PAD.left;
|
|
96
|
+
const padRight = formatValue && lastValue !== null
|
|
97
|
+
? Math.max(PAD.right, Math.ceil(valueText(lastValue).length * CHAR_W) + 14)
|
|
98
|
+
: PAD.right;
|
|
99
|
+
const plotW = Math.max(0, width - padLeft - padRight);
|
|
65
100
|
const plotH = height - PAD.top - PAD.bottom;
|
|
66
101
|
const span = Math.max(1, to - from);
|
|
67
|
-
const x = (ts: number) =>
|
|
102
|
+
const x = (ts: number) => padLeft + ((ts - from) / span) * plotW;
|
|
68
103
|
const y = (v: number) => PAD.top + plotH - (Math.max(0, Math.min(yMax, v)) / yMax) * plotH;
|
|
69
104
|
|
|
70
105
|
const valid = useMemo(() => points.filter((p) => p.value !== null), [points]);
|
|
@@ -95,7 +130,8 @@ export function TimeSeriesChart({
|
|
|
95
130
|
|
|
96
131
|
const last = valid[valid.length - 1];
|
|
97
132
|
const shown = active !== null ? valid[active] : null;
|
|
98
|
-
const fmt = (v: number | null | undefined) =>
|
|
133
|
+
const fmt = (v: number | null | undefined) =>
|
|
134
|
+
v === null || v === undefined ? '—' : valueText(v);
|
|
99
135
|
|
|
100
136
|
function onPointerMove(e: PointerEvent<SVGRectElement>) {
|
|
101
137
|
if (!valid.length || plotW <= 0) return;
|
|
@@ -137,9 +173,9 @@ export function TimeSeriesChart({
|
|
|
137
173
|
<svg width={width} height={height} aria-hidden="true">
|
|
138
174
|
{[0, 50, 100].map((v) => (
|
|
139
175
|
<g key={v}>
|
|
140
|
-
<line x1={
|
|
141
|
-
<text x={
|
|
142
|
-
{
|
|
176
|
+
<line x1={padLeft} x2={padLeft + plotW} y1={y((v / 100) * yMax)} y2={y((v / 100) * yMax)} className="ui-chart__grid" />
|
|
177
|
+
<text x={padLeft - 6} y={y((v / 100) * yMax) + 3} textAnchor="end" className="ui-chart__tick">
|
|
178
|
+
{tickText((v / 100) * yMax)}
|
|
143
179
|
</text>
|
|
144
180
|
</g>
|
|
145
181
|
))}
|
|
@@ -157,7 +193,7 @@ export function TimeSeriesChart({
|
|
|
157
193
|
<defs>
|
|
158
194
|
{/* Marks stay inside the plot, never over the axis labels. */}
|
|
159
195
|
<clipPath id={clipId}>
|
|
160
|
-
<rect x={
|
|
196
|
+
<rect x={padLeft} y={0} width={plotW} height={PAD.top + plotH} />
|
|
161
197
|
</clipPath>
|
|
162
198
|
</defs>
|
|
163
199
|
<g clipPath={`url(#${clipId})`}>
|
|
@@ -182,10 +218,10 @@ export function TimeSeriesChart({
|
|
|
182
218
|
</g>
|
|
183
219
|
)}
|
|
184
220
|
{!valid.length && (
|
|
185
|
-
<text x={
|
|
221
|
+
<text x={padLeft + plotW / 2} y={PAD.top + plotH / 2} textAnchor="middle" className="ui-chart__tick">No samples in this range</text>
|
|
186
222
|
)}
|
|
187
223
|
<rect
|
|
188
|
-
x={
|
|
224
|
+
x={padLeft}
|
|
189
225
|
y={PAD.top}
|
|
190
226
|
width={plotW}
|
|
191
227
|
height={plotH}
|
|
@@ -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' }}>
|