@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.
- package/README.md +6 -2
- package/app/agents/CostSection.tsx +12 -10
- package/app/agents/PageClient.tsx +7 -0
- package/app/agents/PanelRow.tsx +9 -0
- package/app/agents/ResourceSection.tsx +105 -0
- package/app/api/assistant/route.ts +2 -2
- package/app/api/containers/route.ts +18 -2
- package/app/api/costs/agent/route.ts +20 -3
- package/app/api/gateway/provider/route.ts +20 -2
- 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/update-check/route.ts +20 -11
- package/app/components/ui/Accordion.tsx +45 -0
- package/app/components/ui/TimeSeriesChart.tsx +20 -2
- package/app/components/ui/index.ts +1 -0
- package/app/containers/ContainersClient.tsx +48 -6
- package/app/gateway/ModelDetailsModal.tsx +4 -2
- package/app/gateway/PageClient.tsx +86 -36
- package/app/globals.css +36 -0
- package/app/system/AgentCharts.tsx +138 -0
- package/app/system/AgentsSection.tsx +281 -0
- package/app/system/PageClient.tsx +11 -9
- package/app/system/RecentAlerts.tsx +72 -0
- package/app/system/SystemSkeleton.tsx +53 -1
- package/app/system/loading.tsx +5 -1
- package/bin/postinstall.js +7 -4
- package/bin/rev4a.js +6 -7
- package/daemon.js +195 -4
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/FRONTEND-ARCHITECTURE.md +13 -4
- package/docs/REV4A.md +9 -6
- package/docs/dev/API-REFERENCE.md +116 -30
- package/docs/dev/DATABASE.md +58 -2
- package/docs/dev/GATEWAY.md +13 -4
- package/docs/rag/DATA-FRESHNESS.md +16 -1
- package/docs/rag/GLOSSARY.md +3 -3
- package/docs/rag/REV4A-OVERVIEW.md +9 -5
- package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
- package/lib/container-metrics.ts +340 -0
- package/lib/docker-socket-path.d.ts +9 -0
- package/lib/docker-socket-path.js +133 -0
- package/lib/docker-socket.ts +10 -99
- package/lib/docker-stats.d.ts +88 -0
- package/lib/docker-stats.js +284 -0
- package/lib/metrics-db.ts +15 -6
- package/lib/model-details.ts +3 -3
- package/lib/model-pricing.ts +6 -4
- package/lib/utils/format.ts +29 -3
- package/model-details.json +2799 -2274
- package/model-pricing.json +63 -48
- package/models.config.json +41 -10
- package/npm-shrinkwrap.json +1979 -0
- package/package.json +11 -9
- package/scripts/check-language.mjs +1 -1
- package/scripts/check-package-types.mjs +74 -0
- 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
|
|
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 (
|
|
64
|
+
actions: ['View every container, running or stopped, with its details', 'Click "Terminal" on any container to open an interactive shell', 'Check each container\'s status, image, IP and ports, and what a running one consumes now (CPU share, memory, processes), with a link to its charts on the System page'],
|
|
65
65
|
hints: 'Containers marked with an agentId belong to OpenClaw agents. Others are infrastructure containers like databases or reverse proxies.',
|
|
66
66
|
},
|
|
67
67
|
'/containers/terminal/[id]': {
|
|
@@ -2,6 +2,7 @@ import { type NextRequest } from "next/server";
|
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
3
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
4
4
|
import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
|
|
5
|
+
import { primaryName } from '@/lib/docker-stats';
|
|
5
6
|
|
|
6
7
|
export const dynamic = 'force-dynamic';
|
|
7
8
|
|
|
@@ -22,7 +23,9 @@ export async function GET(request: NextRequest) {
|
|
|
22
23
|
const ip = Object.values(networks)[0]?.IPAddress || null;
|
|
23
24
|
return {
|
|
24
25
|
id: c.Id?.slice(0, 12),
|
|
25
|
-
|
|
26
|
+
// The daemon's rule (the name with one leading slash), so the usage row and the
|
|
27
|
+
// /system?agent= link key on the same name the metrics store uses.
|
|
28
|
+
name: primaryName(c?.Names),
|
|
26
29
|
image: c.Image,
|
|
27
30
|
status: c.Status,
|
|
28
31
|
state: c.State,
|
|
@@ -33,7 +36,20 @@ export async function GET(request: NextRequest) {
|
|
|
33
36
|
created: c.Labels?.created || null,
|
|
34
37
|
};
|
|
35
38
|
} catch {
|
|
36
|
-
|
|
39
|
+
// Identity comes from the list: a failed inspect must neither drop the container nor
|
|
40
|
+
// give it a name the metrics store does not key on. Only the inspect-only fields are lost.
|
|
41
|
+
return {
|
|
42
|
+
id: c.Id?.slice(0, 12),
|
|
43
|
+
name: primaryName(c?.Names),
|
|
44
|
+
image: c.Image,
|
|
45
|
+
status: c.Status,
|
|
46
|
+
state: c.State,
|
|
47
|
+
ports: c.Ports?.map((p: any) => `${p.PublicPort || '?'}:${p.PrivatePort || '?'}`).join(', ') || '',
|
|
48
|
+
ip: null,
|
|
49
|
+
agentId: c.Labels?.AGENT_ID || null,
|
|
50
|
+
displayName: null,
|
|
51
|
+
created: c.Labels?.created || null,
|
|
52
|
+
};
|
|
37
53
|
}
|
|
38
54
|
}));
|
|
39
55
|
|
|
@@ -1,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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
+
}
|
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 {
|
|
@@ -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
|
|
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
|
-
|
|
130
|
-
|
|
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
|
+
}
|