@flame0510/project-aether 1.10.0 → 1.11.1

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 (57) hide show
  1. package/README.md +6 -2
  2. package/app/agents/CostSection.tsx +12 -10
  3. package/app/agents/PageClient.tsx +7 -0
  4. package/app/agents/PanelRow.tsx +9 -0
  5. package/app/agents/ResourceSection.tsx +105 -0
  6. package/app/api/assistant/route.ts +2 -2
  7. package/app/api/containers/route.ts +18 -2
  8. package/app/api/costs/agent/route.ts +20 -3
  9. package/app/api/gateway/provider/route.ts +20 -2
  10. package/app/api/metrics/alerts/route.ts +47 -0
  11. package/app/api/metrics/containers/route.ts +54 -0
  12. package/app/api/metrics/route.ts +4 -6
  13. package/app/api/update-check/route.ts +20 -11
  14. package/app/components/ui/Accordion.tsx +45 -0
  15. package/app/components/ui/TimeSeriesChart.tsx +20 -2
  16. package/app/components/ui/index.ts +1 -0
  17. package/app/containers/ContainersClient.tsx +48 -6
  18. package/app/gateway/ModelDetailsModal.tsx +4 -2
  19. package/app/gateway/PageClient.tsx +86 -36
  20. package/app/globals.css +36 -0
  21. package/app/system/AgentCharts.tsx +138 -0
  22. package/app/system/AgentsSection.tsx +281 -0
  23. package/app/system/PageClient.tsx +11 -9
  24. package/app/system/RecentAlerts.tsx +72 -0
  25. package/app/system/SystemSkeleton.tsx +53 -1
  26. package/app/system/loading.tsx +5 -1
  27. package/bin/postinstall.js +7 -4
  28. package/bin/rev4a.js +6 -7
  29. package/daemon.js +195 -4
  30. package/docs/ARCHITECTURE.md +36 -5
  31. package/docs/FRONTEND-ARCHITECTURE.md +13 -4
  32. package/docs/REV4A.md +9 -6
  33. package/docs/dev/API-REFERENCE.md +116 -30
  34. package/docs/dev/DATABASE.md +58 -2
  35. package/docs/dev/GATEWAY.md +13 -4
  36. package/docs/rag/DATA-FRESHNESS.md +16 -1
  37. package/docs/rag/GLOSSARY.md +3 -3
  38. package/docs/rag/REV4A-OVERVIEW.md +9 -5
  39. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
  40. package/lib/container-metrics.ts +340 -0
  41. package/lib/docker-socket-path.d.ts +9 -0
  42. package/lib/docker-socket-path.js +133 -0
  43. package/lib/docker-socket.ts +10 -99
  44. package/lib/docker-stats.d.ts +88 -0
  45. package/lib/docker-stats.js +284 -0
  46. package/lib/metrics-db.ts +15 -6
  47. package/lib/model-details.ts +3 -3
  48. package/lib/model-pricing.ts +6 -4
  49. package/lib/utils/format.ts +29 -3
  50. package/model-details.json +2799 -2274
  51. package/model-pricing.json +63 -48
  52. package/models.config.json +41 -10
  53. package/npm-shrinkwrap.json +1979 -0
  54. package/package.json +11 -9
  55. package/scripts/check-language.mjs +1 -1
  56. package/scripts/check-package-types.mjs +74 -0
  57. 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
 
@@ -39,6 +39,10 @@ rev4a serve
39
39
 
40
40
  Rev4a auto-builds on first run, generates `.env` with credentials,
41
41
  and starts all services. Open `http://localhost:3740` and follow the setup wizard.
42
+ An npm install also builds the dashboard during installation; it fails if the
43
+ production build cannot complete. Startup checks for `.next/BUILD_ID` and retries an
44
+ incomplete build before starting the server. The published package includes a dependency
45
+ lock and the TypeScript packages needed to build from a global installation.
42
46
 
43
47
  ```bash
44
48
  # Custom port:
@@ -84,7 +88,7 @@ already filled in — you normally never set these by hand. See
84
88
  | `REV4A_DB` | `<data dir>/data/events.db` | No | SQLite database path |
85
89
  | `REV4A_WORKSPACE_ROOT` | — | No | Root exposed by the local file explorer |
86
90
  | `REV4A_DEV_ORIGINS` | — | No | Extra comma-separated hostnames or IPs allowed to reach `next dev` (e.g. a LAN address, without scheme or port); no effect on a production build |
87
- | `REV4A_SKIP_POSTINSTALL_BUILD` | — | No | `1` in CI so `npm ci` skips the post-install `next build`; the workflows build as a separate step. Any non-empty value skips it, `0` included |
91
+ | `REV4A_SKIP_POSTINSTALL_BUILD` | — | No | `1` in CI so `npm ci` skips the post-install `next build`; the workflows build as a separate step. Outside CI, a failed post-install build fails the installation. Any non-empty value skips it, `0` included |
88
92
  | `REV4A_AGENT_IMAGE_REGISTRY` | `ghcr.io/flame0510/rev4a/openclaw-agent-base` | No | Registry repository agent images are pulled from, as `<repository>:<OpenClaw version>`. A `localhost` registry is reached over plain HTTP |
89
93
  | `REV4A_DOCKER_WAIT_SECONDS` | `90` | No | How long `rev4a serve` waits for the Docker daemon at start-up before starting without it. Capped at 600; `0` checks once; a negative or non-numeric value means 90. Read from the process environment, not from the `.env` file |
90
94
 
@@ -8,10 +8,13 @@ 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;
14
15
  intervalS: number;
16
+ /** The vendor band billed right now (DeepSeek peak / off-peak), when one of its models is on offer. */
17
+ pricing?: { vendor: string; band: 'peak' | 'off-peak'; nextChange: number | null; source: string } | null;
15
18
  today?: number;
16
19
  week?: number;
17
20
  month?: number;
@@ -31,8 +34,6 @@ function formatAge(ms: number): string {
31
34
  return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
32
35
  }
33
36
 
34
- const ROW = { display: 'flex', justifyContent: 'space-between', padding: '7px 14px', borderBottom: '1px solid var(--border)', fontSize: 12 } as const;
35
-
36
37
  export default function CostSection({ container }: { container: string }) {
37
38
  const [data, setData] = useState<AgentCosts | null>(null);
38
39
  const [error, setError] = useState('');
@@ -70,23 +71,24 @@ export default function CostSection({ container }: { container: string }) {
70
71
  }
71
72
 
72
73
  const stale = !data.collectedAt || Date.now() - data.collectedAt > data.intervalS * 3 * 1000;
73
- const rows: [string, string][] = [
74
+ const utcTime = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' });
75
+ // What the gateway charges right now: the band the agent's calls are billed at until the
76
+ // next change (the rows below are history, priced as each call happened).
77
+ const rows: [string, string][] = data.pricing ? [
78
+ ['Rates now', `${data.pricing.vendor} ${data.pricing.band}${data.pricing.nextChange ? ` · until ${utcTime.format(new Date(data.pricing.nextChange))} UTC` : ''}`],
79
+ ] : [];
80
+ rows.push(
74
81
  ['Today (UTC)', formatCost(data.today)],
75
82
  ['Last 7 days', formatCost(data.week)],
76
83
  ['Last 30 days', `${formatCost(data.month)} · ${Math.round((data.tokensMonth ?? 0) / 1000)}k tokens`],
77
84
  ['Top model (30 days)', data.topModel ? `${data.topModel.model} · ${formatCost(data.topModel.cost)}` : '-'],
78
- ];
85
+ );
79
86
  if (data.unpricedMonth) rows.push(['Calls without a price (30 days)', String(data.unpricedMonth)]);
80
87
  rows.push(['Last read', data.collectedAt ? formatAge(Date.now() - data.collectedAt) : 'never']);
81
88
 
82
89
  return (
83
90
  <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
- ))}
91
+ {rows.map(([label, value]) => <PanelRow key={label} label={label} value={value} />)}
90
92
  {(stale || data.error) && (
91
93
  <div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
92
94
  {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 (no CPU or memory figures are shown)'],
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
- name: (c.Names?.[0] || '').replace(/^\//, ''),
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
- return { id: c.Id?.slice(0, 12), name: (c.Names?.[0] || '').replace(/^\//, ''), status: c.Status, error: 'inspect failed' };
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,11 +1,25 @@
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 { loadOfferedModels } from '@/lib/model-catalogue';
5
+ import { nextPriceChange, priceBandAt, priceScheduleFor } from '@/lib/model-pricing';
6
+ import { CONTAINER_NAME_RE } from '@/lib/docker-stats';
4
7
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
8
 
6
9
  export const dynamic = 'force-dynamic';
7
10
 
8
- const CONTAINER_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
11
+ /** The band DeepSeek bills at right now, when one of its models is on offer — the same object the Costs page header shows. Never throws: the panel's poll must not 500 over a broken key or catalogue file. */
12
+ function currentPricing() {
13
+ try {
14
+ const scheduled = loadOfferedModels().map((m) => m.id).filter((id) => priceScheduleFor(id));
15
+ if (!scheduled.length) return null;
16
+ const pricingAt = new Date();
17
+ const next = nextPriceChange(scheduled, pricingAt);
18
+ return { vendor: 'DeepSeek', band: priceBandAt(scheduled[0], pricingAt), nextChange: next ? next.getTime() : null, source: priceScheduleFor(scheduled[0])!.source };
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
9
23
 
10
24
  /**
11
25
  * GET /api/costs/agent?container=<name> — one agent's spend for its panel: today, the
@@ -17,7 +31,9 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
17
31
  if (denied) return denied as NextResponse;
18
32
 
19
33
  const container = request.nextUrl.searchParams.get('container') ?? '';
20
- if (!CONTAINER_RE.test(container)) return NextResponse.json({ error: 'Invalid container name' }, { status: 400 });
34
+ if (!CONTAINER_NAME_RE.test(container)) return NextResponse.json({ error: 'Invalid container name' }, { status: 400 });
35
+
36
+ const pricing = currentPricing();
21
37
 
22
38
  let db;
23
39
  try {
@@ -25,7 +41,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
25
41
  } catch (e) {
26
42
  return NextResponse.json({ error: `costs.db could not be read: ${(e as Error).message}` }, { status: 500 });
27
43
  }
28
- if (!db) return NextResponse.json({ available: false, intervalS: COLLECT_INTERVAL_S });
44
+ if (!db) return NextResponse.json({ available: false, intervalS: COLLECT_INTERVAL_S, pricing });
29
45
 
30
46
  try {
31
47
  const dates = lastDays(30);
@@ -39,6 +55,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
39
55
  return NextResponse.json({
40
56
  available: true,
41
57
  intervalS: COLLECT_INTERVAL_S,
58
+ pricing,
42
59
  today: sum(1),
43
60
  week: sum(7),
44
61
  month: s.totals.cost,
@@ -10,7 +10,7 @@ import { execSync } from 'child_process';
10
10
  import { readProviderKeys, writeProviderKeys } from './keys';
11
11
  import { dockerUnreachableReason, syncAllAgents, type SyncOutcome } from '../sync';
12
12
  import { detectProviderGatewayUrl } from '@/lib/docker-utils';
13
- import { getPricing } from '@/lib/model-pricing';
13
+ import { getPricing, nextPriceChange, priceAt, priceBandAt } from '@/lib/model-pricing';
14
14
  import { getModelParams } from '@/lib/model-details';
15
15
  import { PROVIDER_LABELS } from '@/lib/provider-labels';
16
16
  import { loadModelsConfig, toggleModelOverride, type ModelConfigEntry } from '@/lib/model-catalogue';
@@ -172,6 +172,7 @@ export async function GET(request: NextRequest) {
172
172
  // Sizes for the row's "next to the price" hint: open-weight models only (see
173
173
  // docs/dev/GATEWAY.md), absent for a model whose vendor does not publish one.
174
174
  const paramsByModel = getModelParams();
175
+ const pricingAt = new Date();
175
176
 
176
177
  const providers = Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => {
177
178
  const providerModels = allModels.filter((m) => m.provider === key);
@@ -190,12 +191,29 @@ export async function GET(request: NextRequest) {
190
191
  }
191
192
  if (m.modality) entry.modality = m.modality;
192
193
  if (paramsByModel[m.id]) entry.params = paramsByModel[m.id];
194
+ // The band billed right now for a time-of-day vendor (DeepSeek peak / off-peak):
195
+ // null for every model priced the same all day. The synced agent prices follow it
196
+ // (lib/price-schedule-sync.ts); the row shows the same thing — including the price,
197
+ // which is the rate in force now, not the peak list price.
198
+ const band = priceBandAt(m.id, pricingAt);
199
+ if (band) {
200
+ entry.band = band;
201
+ // The live OpenRouter overlay outranks the static band arithmetic (disjoint
202
+ // today — only `deepseek` is scheduled, only `openrouter/*` is live-priced —
203
+ // but keep it explicit so a future schedule never silent-replaces live prices).
204
+ if (!entry.pricingLive) {
205
+ const now = priceAt(m.id, pricingAt);
206
+ if (now) entry.pricing = now;
207
+ }
208
+ }
193
209
  return entry;
194
210
  }),
195
211
  ...(providerKeys[key] ? { apiKey: providerKeys[key] } : {}),
196
212
  };
197
213
  });
198
- return NextResponse.json({ providers });
214
+ // When the band flips next, so an open page can refresh the chips right after it.
215
+ const nextChange = nextPriceChange(allModels.map((m) => m.id), pricingAt);
216
+ return NextResponse.json({ providers, nextPriceChangeMs: nextChange ? nextChange.getTime() : null });
199
217
  }
200
218
 
201
219
  /**
@@ -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 {
@@ -1,8 +1,9 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { execSync } from 'child_process';
3
- import { readFileSync } from 'fs';
2
+ import { execSync, spawn } from 'child_process';
3
+ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
+ import { REV4A_DATA } from '@/lib/rev4a-paths';
6
7
 
7
8
  // Fallback used only when package.json cannot be read — the published npm
8
9
  // package name is the single source of truth, so never hardcode it elsewhere.
@@ -103,14 +104,15 @@ export async function POST(request: Request): Promise<NextResponse> {
103
104
  const denied = await requireAuthJWT(request);
104
105
  if (denied) return denied;
105
106
 
106
- const { spawn } = require('child_process');
107
- const { join } = require('path');
108
- const { openSync, closeSync, appendFileSync } = require('fs');
109
-
110
- const dataDir = process.env.REV4A_DATA_DIR ||
111
- join(process.env.HOME || '/root', '.config', 'rev4a', 'data');
107
+ const dataDir = join(REV4A_DATA, 'data');
112
108
  const logPath = join(dataDir, 'update.log');
113
109
 
110
+ try {
111
+ mkdirSync(dataDir, { recursive: true });
112
+ } catch {
113
+ return NextResponse.json({ success: false, error: 'Could not prepare the update directory.' }, { status: 500 });
114
+ }
115
+
114
116
  let log: number | null = null;
115
117
  try {
116
118
  log = openSync(logPath, 'a');
@@ -126,9 +128,16 @@ export async function POST(request: Request): Promise<NextResponse> {
126
128
  });
127
129
  if (log !== null) closeSync(log);
128
130
 
129
- child.on('error', (e: Error) => {
130
- try { appendFileSync(logPath, `[dashboard] could not start the update: ${e.message}\n`); } catch {}
131
- });
131
+ try {
132
+ await new Promise<void>((resolve, reject) => {
133
+ child.once('spawn', resolve);
134
+ child.once('error', reject);
135
+ });
136
+ } catch (e) {
137
+ const message = e instanceof Error ? e.message : String(e);
138
+ try { appendFileSync(logPath, `[dashboard] could not start the update: ${message}\n`); } catch {}
139
+ return NextResponse.json({ success: false, error: 'Could not start the update.' }, { status: 500 });
140
+ }
132
141
  child.unref();
133
142
 
134
143
  return NextResponse.json({
@@ -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
+ }