@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.
Files changed (65) hide show
  1. package/README.md +3 -3
  2. package/app/agents/CostSection.tsx +94 -0
  3. package/app/agents/PageClient.tsx +14 -0
  4. package/app/agents/PanelRow.tsx +9 -0
  5. package/app/agents/ResourceSection.tsx +105 -0
  6. package/app/api/assistant/route.ts +10 -3
  7. package/app/api/containers/route.ts +18 -2
  8. package/app/api/costs/agent/route.ts +55 -0
  9. package/app/api/costs/route.ts +19 -77
  10. package/app/api/costs/usage/route.ts +65 -0
  11. package/app/api/gateway/sync.ts +18 -4
  12. package/app/api/metrics/alerts/route.ts +47 -0
  13. package/app/api/metrics/containers/route.ts +54 -0
  14. package/app/api/metrics/route.ts +4 -6
  15. package/app/api/stats/route.ts +2 -3
  16. package/app/api/stats-since/route.ts +1 -1
  17. package/app/api/stream/route.ts +3 -5
  18. package/app/api/system-health/route.ts +33 -32
  19. package/app/components/CostBreakdown.tsx +1 -1
  20. package/app/components/Sidebar.tsx +10 -0
  21. package/app/components/SystemCockpit.tsx +1 -1
  22. package/app/components/ui/Accordion.tsx +45 -0
  23. package/app/components/ui/TimeSeriesChart.tsx +47 -11
  24. package/app/components/ui/index.ts +1 -0
  25. package/app/containers/ContainersClient.tsx +48 -6
  26. package/app/costs/CostsSkeleton.tsx +88 -0
  27. package/app/costs/PageClient.tsx +366 -0
  28. package/app/costs/loading.tsx +13 -0
  29. package/app/costs/page.tsx +5 -0
  30. package/app/globals.css +42 -0
  31. package/app/system/AgentCharts.tsx +138 -0
  32. package/app/system/AgentsSection.tsx +281 -0
  33. package/app/system/PageClient.tsx +7 -7
  34. package/app/system/RecentAlerts.tsx +72 -0
  35. package/app/system/SystemSkeleton.tsx +53 -1
  36. package/app/system/loading.tsx +5 -1
  37. package/daemon.js +646 -41
  38. package/docs/ARCHITECTURE.md +72 -31
  39. package/docs/DESIGN-SYSTEM.md +2 -2
  40. package/docs/FRONTEND-ARCHITECTURE.md +14 -3
  41. package/docs/REV4A.md +6 -5
  42. package/docs/dev/API-REFERENCE.md +208 -38
  43. package/docs/dev/DATABASE.md +176 -20
  44. package/docs/dev/GATEWAY.md +53 -9
  45. package/docs/rag/DATA-FRESHNESS.md +34 -7
  46. package/docs/rag/GLOSSARY.md +8 -5
  47. package/docs/rag/REV4A-OVERVIEW.md +10 -3
  48. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -3
  49. package/instrumentation.ts +11 -0
  50. package/lib/agent-costs.ts +172 -0
  51. package/lib/container-metrics.ts +340 -0
  52. package/lib/cost-reconciliation.ts +78 -0
  53. package/lib/costs-db.ts +31 -0
  54. package/lib/docker-socket-path.js +133 -0
  55. package/lib/docker-socket.ts +10 -99
  56. package/lib/docker-stats.js +284 -0
  57. package/lib/metrics-db.ts +15 -6
  58. package/lib/model-pricing.ts +123 -3
  59. package/lib/price-schedule-sync.ts +133 -0
  60. package/lib/utils/format.ts +38 -0
  61. package/model-pricing.json +269 -121
  62. package/package.json +1 -1
  63. package/scripts/refresh-model-pricing.mjs +16 -4
  64. package/scripts/test-docker-stats.mjs +270 -0
  65. 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
+ }
@@ -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 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.
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 {
@@ -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
- const startOfMonth = Math.floor(
13
- new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000,
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(Math.floor(ts / 1000)) as { total: number } | undefined;
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) {
@@ -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 { summarizeBilling } from '@/lib/billing';
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(Math.floor(Date.now() / 1000) - 86400) as { total: number }).total
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
- const startOfDay = new Date();
172
- startOfDay.setHours(0, 0, 0, 0);
173
- const todayModels = db.prepare(
174
- `SELECT model,
175
- COALESCE(SUM(cost_usd), 0) AS cost_usd,
176
- COALESCE(SUM(tokens_in), 0) AS tokens_in,
177
- COALESCE(SUM(tokens_out), 0) AS tokens_out,
178
- COUNT(*) AS sessions
179
- FROM sessions WHERE started_at >= ?
180
- GROUP BY model ORDER BY cost_usd DESC`,
181
- ).all(Math.floor(startOfDay.getTime() / 1000)) as ModelCost[];
182
- const billing = summarizeBilling(todayModels);
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: 'Usage-based cost today',
186
- health: billing.usageBasedCost > 10 ? 'warning' : 'ok',
187
- value: `$${billing.usageBasedCost.toFixed(2)}`,
188
- details: `DB estimate $${billing.dbEstimatedCost.toFixed(2)}; fixed ${billing.buckets.fixed.sessions} sessions; credits ${billing.buckets.credits.sessions} sessions`,
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 (billing.usageBasedCost > 10) {
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 usage-based cost',
197
- details: `Provider usage-based today: $${billing.usageBasedCost.toFixed(2)}. Counts OpenRouter and the other pay-per-use providers, not the fixed plans.`,
198
- actionHref: '/gateway',
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 (billing.unknownModels.length > 0 && billing.buckets.unknown.cost_usd > 1) {
208
+ if (missing > 0) {
204
209
  recommendations.push({
205
- id: 'cost.classify-unknown-models',
210
+ id: 'cost.unpriced-calls',
206
211
  severity: 'warning',
207
212
  source: 'cost',
208
- title: 'Unclassified models in billing',
209
- details: `${billing.unknownModels.length} unclassified models have DB estimate $${billing.buckets.unknown.cost_usd.toFixed(2)}.`,
210
- actionHref: '/gateway',
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>Costs by Model (Today)</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="Usage cost" value={String(costCheck?.value ?? fmtMoney(0))} subtitle={costCheck?.details ?? `DB estimate ${fmtMoney(costs.today)}`} tone="accent" loading={systemLoading} />
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
- yMax?: number;
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 plotW = Math.max(0, width - PAD.left - PAD.right);
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) => PAD.left + ((ts - from) / span) * plotW;
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) => (v === null || v === undefined ? '—' : `${Math.round(v * 10) / 10}${unit}`);
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={PAD.left} x2={PAD.left + plotW} y1={y((v / 100) * yMax)} y2={y((v / 100) * yMax)} className="ui-chart__grid" />
141
- <text x={PAD.left - 6} y={y((v / 100) * yMax) + 3} textAnchor="end" className="ui-chart__tick">
142
- {Math.round((v / 100) * yMax)}{unit}
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={PAD.left} y={0} width={plotW} height={PAD.top + plotH} />
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={PAD.left + plotW / 2} y={PAD.top + plotH / 2} textAnchor="middle" className="ui-chart__tick">No samples in this range</text>
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={PAD.left}
224
+ x={padLeft}
189
225
  y={PAD.top}
190
226
  width={plotW}
191
227
  height={plotH}
@@ -1,3 +1,4 @@
1
+ export * from './Accordion';
1
2
  export * from './ConfirmModal';
2
3
  export * from './Badge';
3
4
  export * from './Button';
@@ -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 fetch('/api/containers', { signal });
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
- useEffect(() => {
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
- const i = setInterval(() => { if (!controller.signal.aborted) load(controller.signal); }, 15000);
42
- return () => { controller.abort(); clearInterval(i); };
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={() => load()}>↻</Button>
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' }}>