@flame0510/project-aether 1.11.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 +5 -1
- package/app/agents/CostSection.tsx +10 -2
- package/app/api/costs/agent/route.ts +19 -1
- package/app/api/gateway/provider/route.ts +20 -2
- package/app/api/update-check/route.ts +20 -11
- package/app/gateway/ModelDetailsModal.tsx +4 -2
- package/app/gateway/PageClient.tsx +86 -36
- package/app/system/PageClient.tsx +4 -2
- package/bin/postinstall.js +7 -4
- package/bin/rev4a.js +6 -7
- package/docs/FRONTEND-ARCHITECTURE.md +3 -3
- package/docs/REV4A.md +6 -3
- package/docs/dev/API-REFERENCE.md +26 -14
- package/docs/dev/GATEWAY.md +13 -4
- package/docs/rag/GLOSSARY.md +1 -1
- package/docs/rag/REV4A-OVERVIEW.md +2 -2
- package/lib/docker-socket-path.d.ts +9 -0
- package/lib/docker-stats.d.ts +88 -0
- package/lib/model-details.ts +3 -3
- package/lib/model-pricing.ts +6 -4
- 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/README.md
CHANGED
|
@@ -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
|
|
|
@@ -13,6 +13,8 @@ import { PanelRow } from './PanelRow';
|
|
|
13
13
|
interface AgentCosts {
|
|
14
14
|
available: boolean;
|
|
15
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;
|
|
16
18
|
today?: number;
|
|
17
19
|
week?: number;
|
|
18
20
|
month?: number;
|
|
@@ -69,12 +71,18 @@ export default function CostSection({ container }: { container: string }) {
|
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
const stale = !data.collectedAt || Date.now() - data.collectedAt > data.intervalS * 3 * 1000;
|
|
72
|
-
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(
|
|
73
81
|
['Today (UTC)', formatCost(data.today)],
|
|
74
82
|
['Last 7 days', formatCost(data.week)],
|
|
75
83
|
['Last 30 days', `${formatCost(data.month)} · ${Math.round((data.tokensMonth ?? 0) / 1000)}k tokens`],
|
|
76
84
|
['Top model (30 days)', data.topModel ? `${data.topModel.model} · ${formatCost(data.topModel.cost)}` : '-'],
|
|
77
|
-
|
|
85
|
+
);
|
|
78
86
|
if (data.unpricedMonth) rows.push(['Calls without a price (30 days)', String(data.unpricedMonth)]);
|
|
79
87
|
rows.push(['Last read', data.collectedAt ? formatAge(Date.now() - data.collectedAt) : 'never']);
|
|
80
88
|
|
|
@@ -1,11 +1,26 @@
|
|
|
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';
|
|
4
6
|
import { CONTAINER_NAME_RE } from '@/lib/docker-stats';
|
|
5
7
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
8
|
|
|
7
9
|
export const dynamic = 'force-dynamic';
|
|
8
10
|
|
|
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
|
+
}
|
|
23
|
+
|
|
9
24
|
/**
|
|
10
25
|
* GET /api/costs/agent?container=<name> — one agent's spend for its panel: today, the
|
|
11
26
|
* last 7 and 30 UTC days, its top model and unpriced calls over 30 days, and when it
|
|
@@ -18,13 +33,15 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
18
33
|
const container = request.nextUrl.searchParams.get('container') ?? '';
|
|
19
34
|
if (!CONTAINER_NAME_RE.test(container)) return NextResponse.json({ error: 'Invalid container name' }, { status: 400 });
|
|
20
35
|
|
|
36
|
+
const pricing = currentPricing();
|
|
37
|
+
|
|
21
38
|
let db;
|
|
22
39
|
try {
|
|
23
40
|
db = openCostsDb();
|
|
24
41
|
} catch (e) {
|
|
25
42
|
return NextResponse.json({ error: `costs.db could not be read: ${(e as Error).message}` }, { status: 500 });
|
|
26
43
|
}
|
|
27
|
-
if (!db) return NextResponse.json({ available: false, intervalS: COLLECT_INTERVAL_S });
|
|
44
|
+
if (!db) return NextResponse.json({ available: false, intervalS: COLLECT_INTERVAL_S, pricing });
|
|
28
45
|
|
|
29
46
|
try {
|
|
30
47
|
const dates = lastDays(30);
|
|
@@ -38,6 +55,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
38
55
|
return NextResponse.json({
|
|
39
56
|
available: true,
|
|
40
57
|
intervalS: COLLECT_INTERVAL_S,
|
|
58
|
+
pricing,
|
|
41
59
|
today: sum(1),
|
|
42
60
|
week: sum(7),
|
|
43
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
|
/**
|
|
@@ -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({
|
|
@@ -24,7 +24,7 @@ interface DetailsView {
|
|
|
24
24
|
modality?: string;
|
|
25
25
|
enabled: boolean;
|
|
26
26
|
deprecated: boolean;
|
|
27
|
-
price: { input: number; output: number; source: 'openrouter' | 'vendor' } | null;
|
|
27
|
+
price: { input: number; output: number; source: 'openrouter' | 'vendor'; scheduled: boolean } | null;
|
|
28
28
|
details: {
|
|
29
29
|
description?: string;
|
|
30
30
|
created?: string;
|
|
@@ -182,7 +182,9 @@ export default function ModelDetailsModal({ modelId, onClose }: { modelId: strin
|
|
|
182
182
|
<div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Price</div>
|
|
183
183
|
<PropertyList rows={[
|
|
184
184
|
{ label: 'Input / output', value: formatPrice(view.price) },
|
|
185
|
-
|
|
185
|
+
// The static file holds the list (peak) rate for a time-of-day vendor; the row
|
|
186
|
+
// behind this modal shows the rate in force. Name the difference, not the vendor twice.
|
|
187
|
+
...(view.price ? [{ label: 'Per 1M tokens, from', value: view.price.scheduled ? `${view.providerLabel} · list price` : view.providerLabel, dim: true }] : []),
|
|
186
188
|
]} />
|
|
187
189
|
|
|
188
190
|
{/* Architecture: the factory facts from the model card, open weights only */}
|
|
@@ -23,6 +23,8 @@ interface ModelInfo {
|
|
|
23
23
|
modality?: string;
|
|
24
24
|
/** Total parameters, when the model's card publishes them (open weights only). */
|
|
25
25
|
params?: number;
|
|
26
|
+
/** The vendor band billed right now (DeepSeek peak / off-peak); absent for fixed-rate models. */
|
|
27
|
+
band?: 'peak' | 'off-peak';
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
interface GatewayProvider {
|
|
@@ -37,6 +39,8 @@ interface GatewayProvider {
|
|
|
37
39
|
|
|
38
40
|
interface ProviderApiResponse {
|
|
39
41
|
providers: GatewayProvider[];
|
|
42
|
+
/** When the band flips next, so an open page refreshes the chips just after it. */
|
|
43
|
+
nextPriceChangeMs?: number | null;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/* ------------------------------------------------------------------ */
|
|
@@ -100,15 +104,25 @@ export default function GatewayPageClient() {
|
|
|
100
104
|
// Accordion state — which provider cards are expanded
|
|
101
105
|
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set());
|
|
102
106
|
const expandedInitRef = useRef(false);
|
|
107
|
+
const loadAbortRef = useRef<AbortController | null>(null);
|
|
108
|
+
const [bandChangeAt, setBandChangeAt] = useState<number | null>(null);
|
|
109
|
+
const [retryAt, setRetryAt] = useState<number | null>(null);
|
|
103
110
|
|
|
104
111
|
/* ---- Data loading ---- */
|
|
105
|
-
const loadAll = useCallback(async () => {
|
|
106
|
-
|
|
112
|
+
const loadAll = useCallback(async (silent = false) => {
|
|
113
|
+
loadAbortRef.current?.abort();
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
loadAbortRef.current = controller;
|
|
116
|
+
if (!silent) setLoading(true);
|
|
107
117
|
try {
|
|
108
|
-
const provRes = await fetch('/api/gateway/provider');
|
|
109
|
-
if (provRes.ok) {
|
|
118
|
+
const provRes = await fetch('/api/gateway/provider', { signal: controller.signal });
|
|
119
|
+
if (!provRes.ok) throw new Error(`Could not load providers (HTTP ${provRes.status})`);
|
|
120
|
+
setError(null);
|
|
121
|
+
setRetryAt(null);
|
|
122
|
+
{
|
|
110
123
|
const provJson: ProviderApiResponse = await provRes.json();
|
|
111
124
|
setProviders(provJson.providers);
|
|
125
|
+
setBandChangeAt(provJson.nextPriceChangeMs ?? null);
|
|
112
126
|
|
|
113
127
|
// Auto-expand configured providers on first load
|
|
114
128
|
if (!expandedInitRef.current) {
|
|
@@ -119,51 +133,79 @@ export default function GatewayPageClient() {
|
|
|
119
133
|
});
|
|
120
134
|
if (configured.size > 0) setExpandedProviders(configured);
|
|
121
135
|
}
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
136
|
+
// A scheduled band refresh only updates model rows. It must not overwrite a key
|
|
137
|
+
// the operator is editing or re-run the slower balance requests.
|
|
138
|
+
if (!silent) {
|
|
139
|
+
// Load keys from provider response (all configured providers now return apiKey)
|
|
140
|
+
const keyEntries: Record<string, string> = {};
|
|
141
|
+
const origEntries: Record<string, string> = {};
|
|
142
|
+
provJson.providers.forEach((p: any) => {
|
|
143
|
+
if (p.configured && p.apiKey) {
|
|
144
|
+
keyEntries[p.provider] = p.apiKey;
|
|
145
|
+
origEntries[p.provider] = p.apiKey;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
setApiKeyInputs((prev) => ({ ...prev, ...keyEntries }));
|
|
149
|
+
setOriginalKeys((prev) => ({ ...prev, ...origEntries }));
|
|
150
|
+
|
|
151
|
+
// Auto-fetch balances for supported providers that have keys configured
|
|
152
|
+
const balanceProvs = provJson.providers
|
|
153
|
+
.filter((p: any) => p.configured && ['deepseek','openrouter','glm'].includes(p.provider))
|
|
154
|
+
.map((p: any) => p.provider);
|
|
155
|
+
for (const bp of balanceProvs) {
|
|
156
|
+
try {
|
|
157
|
+
const bRes = await fetch(`/api/gateway/provider/balance?provider=${bp}`, { signal: controller.signal });
|
|
158
|
+
const bJson = await bRes.json();
|
|
159
|
+
if (bRes.ok && bJson.balance) {
|
|
160
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
|
|
161
|
+
} else if (bJson.reason === 'no-balance-api') {
|
|
162
|
+
// Permanent: this provider has no balance API for the account — hide the badge.
|
|
163
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '', unsupported: true } }));
|
|
164
|
+
} else {
|
|
165
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
|
|
166
|
+
}
|
|
167
|
+
} catch (e: unknown) {
|
|
168
|
+
if ((e as Error)?.name === 'AbortError') throw e;
|
|
169
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: e instanceof Error ? e.message : String(e) } }));
|
|
149
170
|
}
|
|
150
|
-
} catch (e: unknown) {
|
|
151
|
-
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: e instanceof Error ? e.message : String(e) } }));
|
|
152
171
|
}
|
|
153
172
|
}
|
|
154
173
|
}
|
|
155
174
|
} catch (e: unknown) {
|
|
156
|
-
|
|
175
|
+
if ((e as Error)?.name !== 'AbortError') {
|
|
176
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
177
|
+
if (silent) setRetryAt(Date.now() + 60_000);
|
|
178
|
+
}
|
|
157
179
|
} finally {
|
|
158
|
-
|
|
180
|
+
if (loadAbortRef.current === controller) {
|
|
181
|
+
loadAbortRef.current = null;
|
|
182
|
+
setLoading(false);
|
|
183
|
+
}
|
|
159
184
|
}
|
|
160
185
|
}, []);
|
|
161
186
|
|
|
162
|
-
useEffect(() => {
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
void loadAll();
|
|
189
|
+
return () => { loadAbortRef.current?.abort(); };
|
|
190
|
+
}, [loadAll]);
|
|
191
|
+
|
|
192
|
+
// Band chips: when a rate change is due, reload just past it — silently, so an open page
|
|
193
|
+
// flips PEAK / OFF-PEAK with the prices the agents actually get, without flickering skeletons.
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const next = retryAt ?? bandChangeAt;
|
|
196
|
+
if (!next) return;
|
|
197
|
+
const t = setTimeout(() => { void loadAll(true); }, Math.max(5_000, next - Date.now() + (retryAt ? 0 : 2_000)));
|
|
198
|
+
return () => { clearTimeout(t); };
|
|
199
|
+
}, [bandChangeAt, retryAt, loadAll]);
|
|
163
200
|
|
|
164
201
|
/** Format pricing for display — shared with the details modal (app/lib/model-format.ts). */
|
|
165
202
|
const fmtPrice = formatPrice;
|
|
166
203
|
|
|
204
|
+
/** The band chips' tooltip: when the band in force now ends, in UTC. */
|
|
205
|
+
const bandUntilLabel = bandChangeAt
|
|
206
|
+
? new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' }).format(new Date(bandChangeAt))
|
|
207
|
+
: null;
|
|
208
|
+
|
|
167
209
|
/**
|
|
168
210
|
* Local model filter. Matches against both the display name and the id,
|
|
169
211
|
* because the upstream vendor often only appears in one of them — e.g.
|
|
@@ -658,6 +700,14 @@ export default function GatewayPageClient() {
|
|
|
658
700
|
<span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
|
|
659
701
|
{fmtPrice(pricing)}
|
|
660
702
|
</span>
|
|
703
|
+
{m.band && (
|
|
704
|
+
<span
|
|
705
|
+
title={`Time-of-day rates: ${m.band} until ${bandUntilLabel ? `${bandUntilLabel} UTC` : 'the next change'} — the agents' synced prices follow the band.`}
|
|
706
|
+
style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
|
|
707
|
+
>
|
|
708
|
+
<Pill tone={m.band === 'peak' ? 'warning' : 'success'}>{m.band === 'peak' ? 'PEAK' : 'OFF-PEAK'}</Pill>
|
|
709
|
+
</span>
|
|
710
|
+
)}
|
|
661
711
|
{typeof m.params === 'number' && (
|
|
662
712
|
<span
|
|
663
713
|
style={{ marginLeft: 8, color: 'var(--text-dim)' }}
|
|
@@ -54,6 +54,8 @@ interface MetricsPayload {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const ROLE_LABEL: Record<string, string> = { root: 'system', docker: 'Docker data', data: 'Rev4a data' };
|
|
57
|
+
/** A filesystem's display name: the bare "/" is the root volume — clearer as a word. Its mount stays in the aria-label and, for other filesystems, is the name. */
|
|
58
|
+
const diskLabel = (mount: string) => (mount === '/' ? 'Root' : mount);
|
|
57
59
|
|
|
58
60
|
function formatUptime(s: number): string {
|
|
59
61
|
const d = Math.floor(s / 86_400);
|
|
@@ -194,7 +196,7 @@ export default function SystemPageClient() {
|
|
|
194
196
|
{latest?.disks.map((d) => (
|
|
195
197
|
<div key={d.mount} style={{ marginTop: 14 }}>
|
|
196
198
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
|
197
|
-
<span style={{ fontSize: 13 }}>{d.mount}</span>
|
|
199
|
+
<span style={{ fontSize: 13 }}>{diskLabel(d.mount)}</span>
|
|
198
200
|
<span style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
199
201
|
{d.roles.map((r) => <Pill key={r}>{ROLE_LABEL[r] ?? r}</Pill>)}
|
|
200
202
|
</span>
|
|
@@ -240,7 +242,7 @@ export default function SystemPageClient() {
|
|
|
240
242
|
</Surface>
|
|
241
243
|
{(data?.disk_history ?? []).map((d) => (
|
|
242
244
|
<Surface as="section" key={d.mount}>
|
|
243
|
-
<div className="ui-kicker">Storage · {d.mount}</div>
|
|
245
|
+
<div className="ui-kicker">Storage · {diskLabel(d.mount)}</div>
|
|
244
246
|
<TimeSeriesChart
|
|
245
247
|
label={`Storage in use on ${d.mount}`}
|
|
246
248
|
points={d.points.map((p) => ({ ts: p.ts, value: p.percent }))}
|
package/bin/postinstall.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Post-install hook: build Next.js app if
|
|
3
|
+
* Post-install hook: build Next.js app if a complete production build is absent.
|
|
4
4
|
*
|
|
5
5
|
* Skipped when REV4A_SKIP_POSTINSTALL_BUILD is set: CI and the release workflow run
|
|
6
6
|
* `npm run build` as an explicit step, and a second build here would run before every
|
|
7
|
-
* other check
|
|
7
|
+
* other check. A failed build must fail the install so an update cannot restart into
|
|
8
|
+
* an incomplete .next directory.
|
|
8
9
|
*/
|
|
9
10
|
const { existsSync } = require('fs');
|
|
10
11
|
const { join } = require('path');
|
|
11
12
|
|
|
12
13
|
const ROOT = join(__dirname, '..');
|
|
13
|
-
const
|
|
14
|
+
const BUILD_ID = join(ROOT, '.next', 'BUILD_ID');
|
|
14
15
|
|
|
15
|
-
if (!process.env.REV4A_SKIP_POSTINSTALL_BUILD && !existsSync(
|
|
16
|
+
if (!process.env.REV4A_SKIP_POSTINSTALL_BUILD && !existsSync(BUILD_ID)) {
|
|
16
17
|
try {
|
|
17
18
|
const { execSync } = require('child_process');
|
|
18
19
|
console.log('[rev4a] Building dashboard… (one-time, may take a minute)');
|
|
19
20
|
execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
|
|
21
|
+
if (!existsSync(BUILD_ID)) throw new Error('next build returned without a BUILD_ID');
|
|
20
22
|
console.log('[rev4a] Build complete.');
|
|
21
23
|
} catch (e) {
|
|
22
24
|
console.error('[rev4a] Build failed. Run "rev4a build" to retry.');
|
|
25
|
+
process.exitCode = 1;
|
|
23
26
|
}
|
|
24
27
|
}
|
package/bin/rev4a.js
CHANGED
|
@@ -431,6 +431,7 @@ function build() {
|
|
|
431
431
|
// Generous on purpose: a build is the slowest step of an install, and killing it
|
|
432
432
|
// because it passed two minutes takes the server down over slowness alone.
|
|
433
433
|
execSync('npx next build', { stdio: 'inherit', cwd: ROOT, timeout: 30 * 60_000 });
|
|
434
|
+
if (!existsSync(join(NEXT_DIR, 'BUILD_ID'))) throw new Error('next build returned without a BUILD_ID');
|
|
434
435
|
log('BUILD', 'Build complete');
|
|
435
436
|
} catch (err) {
|
|
436
437
|
log('BUILD', `Build failed: ${err.message}`);
|
|
@@ -487,8 +488,8 @@ function preflight() {
|
|
|
487
488
|
}
|
|
488
489
|
ensureRev4aRules();
|
|
489
490
|
|
|
490
|
-
if (!existsSync(NEXT_DIR)) {
|
|
491
|
-
log('PREFLIGHT', 'No build found — running build first…');
|
|
491
|
+
if (!existsSync(join(NEXT_DIR, 'BUILD_ID'))) {
|
|
492
|
+
log('PREFLIGHT', 'No complete build found — running build first…');
|
|
492
493
|
build();
|
|
493
494
|
}
|
|
494
495
|
}
|
|
@@ -769,11 +770,9 @@ if (cmd === 'update') {
|
|
|
769
770
|
stdio: 'inherit',
|
|
770
771
|
timeout: 20 * 60_000,
|
|
771
772
|
});
|
|
772
|
-
//
|
|
773
|
-
|
|
774
|
-
if (existsSync(
|
|
775
|
-
try { execSync(`rm -rf "${nextDir}"`, { stdio: 'inherit' }); } catch {}
|
|
776
|
-
}
|
|
773
|
+
// postinstall builds the freshly installed package and fails the install if it
|
|
774
|
+
// cannot; keep that build for the restart. The fallback covers a skipped build.
|
|
775
|
+
if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) build();
|
|
777
776
|
}
|
|
778
777
|
console.log('Update complete. Restarting…');
|
|
779
778
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Rev4a Frontend Architecture
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-10-
|
|
3
|
+
> **Last updated:** 2026-10-02
|
|
4
4
|
|
|
5
5
|
## Layering
|
|
6
6
|
|
|
@@ -48,9 +48,9 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
|
|
|
48
48
|
| `BackupSection` | inline in `app/agents/PageClient.tsx` | BACKUP section of the agent detail panel, on the cold backup and the restore. **Backup Now** starts `POST /api/agents/[id]/cold-backup`; while the job runs a banner shows the file and its live percent with **Cancel** (`DELETE /cold-backup`). **Restore** (after a confirm) starts `POST /restore` and a banner shows `Restoring <file>…` (no percent: the extract is a single `tar xzf`, and there is no Cancel). Both sections poll their `GET` every 2 s while running, and on mount pick up a job that is already running — a backup lives in a Docker helper, a restore in `agent_restores`, so reloading the page or navigating away never loses them nor allows a second one (the server answers 409 anyway). Delete per row; all actions disabled while one runs; keyed busy state `{ kind, file }` so only the row in action shows the spinner. On the agent list, an activity Badge (fed by `/api/agents/activity-summary`, polled at 2 s only while something runs, otherwise riding the 15 s list poll) reads `BACKUP nn%`, `RESTORING`, `RECREATING`, `EDITING` or `UPDATING`. |
|
|
49
49
|
| `RecreateSection` | inline in `app/agents/PageClient.tsx` | RECREATE section of the agent detail panel. **Recreate Container** starts `POST /api/agents/[id]/recreate` (202) after a confirm; a banner then shows the phase — *Backing up … nn%* while the cold backup runs, *Recreating container…* while the container is rebuilt and the gateway starts. The section polls `GET /recreate` every 2 s, and on mount picks up a recreate that is already running, so a reload or navigation never loses it; it refetches the agent once the job reports `done`. |
|
|
50
50
|
| `Accordion` | `Accordion.tsx` | A row that opens: the header is a real `<button type="button">` (`aria-expanded`, `aria-controls` while open) carrying a `title`, `summary` figures and an optional muted `detail` line, so the answer to "what is this now" is on screen closed; the body holds what takes room and is **mounted only while open**, so a chart inside fetches nothing while closed. Controlled (`open` / `onToggle`: the parent can deep-link or fetch on it), optional `id` for a link to land on. Square, flat, tokens only. On mobile the summary wraps under the title. Used by the System page's agents. |
|
|
51
|
-
| System page | `app/system/PageClient.tsx` | `/system`: CPU, memory and storage of the host, from `GET /api/metrics`. Three cards (CPU with cores and load; memory with available and swap; one `Meter` per filesystem with its roles) — thresholds 85/95 % for CPU and memory, 80/90 % for storage — then a range switch (`Tabs`: 1h · 24h · 7d · 30d) scoping the charts below it: CPU and memory average with peak, one chart per filesystem. Polls every 30 s with an `AbortController` ref (a range change aborts the previous fetch). Times in the configured Rev4a timezone (`useRev4aTimezone`). Before the first answer the cards and charts are skeletons shaped like them (`app/system/SystemSkeleton.tsx`, also the route's `loading.tsx`), so nothing jumps; the hostname line is cut with an ellipsis and never widens the page. Says *No samples yet* before the daemon's first sample and *Not collecting* when the latest sample is older than three intervals. Two columns of charts from lg (992px) up, one below; an odd last chart spans both columns instead of leaving a hole. Under them, **Agents and containers** (`app/system/AgentsSection.tsx`, heading in the accent eyebrow with a rule above — `system-section-heading`): *Docker storage* (images, volumes, writable layers, build cache, each with what no container uses — wording neutral on purpose: an unused image may be a rollback target, an unused volume the cold backups), then **one `Accordion` per container**, sorted by CPU now (`GET /api/metrics/containers`, polled every 30 s with an `AbortController` ref). Closed: name, an *agent* / *container* pill, *stopped* when it is — or *no data* when Docker still lists it but its stats call fails — and CPU (share of Docker's cores), memory, storage, PIDs, with average · peak over the range tabs, cores, network and memory share on a muted line. Open (`AgentCharts.tsx`): the same three charts as the machine — CPU (share), Memory, Storage — for that container, fetched when it opens (`?container=`), polled every 30 s, aborted on close or range change; each with `yMax="auto"` (an agent is 0.01–2 % of Docker's cores) and sizes in MB/GB, on `subtle` surfaces inside the accordion. A last row, *Everything else*, is the machine minus the containers. `/system?agent=<container>` opens one and scrolls to it. Then **Recent alerts** (`RecentAlerts.tsx`, `GET /api/metrics/alerts`): the machine's latest 10 anomalies with time (configured timezone), metric pill and message. The section has its own skeleton (`SystemAgentsSkeleton`, also in the route's `loading.tsx`). |
|
|
51
|
+
| System page | `app/system/PageClient.tsx` | `/system`: CPU, memory and storage of the host, from `GET /api/metrics`. Three cards (CPU with cores and load; memory with available and swap; one `Meter` per filesystem (titled by its mount, the root volume as *Root*) with its roles) — thresholds 85/95 % for CPU and memory, 80/90 % for storage — then a range switch (`Tabs`: 1h · 24h · 7d · 30d) scoping the charts below it: CPU and memory average with peak, one chart per filesystem. Polls every 30 s with an `AbortController` ref (a range change aborts the previous fetch). Times in the configured Rev4a timezone (`useRev4aTimezone`). Before the first answer the cards and charts are skeletons shaped like them (`app/system/SystemSkeleton.tsx`, also the route's `loading.tsx`), so nothing jumps; the hostname line is cut with an ellipsis and never widens the page. Says *No samples yet* before the daemon's first sample and *Not collecting* when the latest sample is older than three intervals. Two columns of charts from lg (992px) up, one below; an odd last chart spans both columns instead of leaving a hole. Under them, **Agents and containers** (`app/system/AgentsSection.tsx`, heading in the accent eyebrow with a rule above — `system-section-heading`): *Docker storage* (images, volumes, writable layers, build cache, each with what no container uses — wording neutral on purpose: an unused image may be a rollback target, an unused volume the cold backups), then **one `Accordion` per container**, sorted by CPU now (`GET /api/metrics/containers`, polled every 30 s with an `AbortController` ref). Closed: name, an *agent* / *container* pill, *stopped* when it is — or *no data* when Docker still lists it but its stats call fails — and CPU (share of Docker's cores), memory, storage, PIDs, with average · peak over the range tabs, cores, network and memory share on a muted line. Open (`AgentCharts.tsx`): the same three charts as the machine — CPU (share), Memory, Storage — for that container, fetched when it opens (`?container=`), polled every 30 s, aborted on close or range change; each with `yMax="auto"` (an agent is 0.01–2 % of Docker's cores) and sizes in MB/GB, on `subtle` surfaces inside the accordion. A last row, *Everything else*, is the machine minus the containers. `/system?agent=<container>` opens one and scrolls to it. Then **Recent alerts** (`RecentAlerts.tsx`, `GET /api/metrics/alerts`): the machine's latest 10 anomalies with time (configured timezone), metric pill and message. The section has its own skeleton (`SystemAgentsSkeleton`, also in the route's `loading.tsx`). |
|
|
52
52
|
| Costs page | `app/costs/PageClient.tsx` | `/costs`: what each agent spent, as its own OpenClaw priced it, from `GET /api/costs/usage`. A range switch (`Tabs`: Today · 7 days · 30 days, UTC days) scopes everything below it: three cards (spent with tokens and days; the cost split into input / output / cache read / cache write; the tokens with the share of input served from cache), the daily spend (`TimeSeriesChart` with dollars, not on Today), then *By agent* and *By model* side by side — each row a name and figure over a share `Meter`, the pattern of the System storage card — *Against the bill* (per vendor, what it billed, from balance or usage readings, next to what the agents priced over the same readings, top-ups apart), and the most expensive sessions. Warnings above: *No figures yet* before the daemon's first pass, *Not current* for agents still being read whose last read failed or is older than three intervals (a stopped or deleted agent keeps its figures unflagged), and *Calls without a price* by model (a model with no price is also marked in *By model*, so a $0.00 is never read as free). The header shows the DeepSeek band now (peak / off-peak, until when, UTC) when a DeepSeek model is on offer. Polls every 60 s with an `AbortController` ref. Skeletons shaped like the content (`app/costs/CostsSkeleton.tsx`, also the route's `loading.tsx`). Costs under a cent keep four decimals. |
|
|
53
|
-
| Agent costs | `app/agents/CostSection.tsx` | The COSTS section of the agent panel, after MODEL: today (UTC), last 7 and 30 days with tokens, the top model, calls without a price, when the agent was last read (a note when not recently: a stopped agent keeps its last figures), and a link to `/costs`. From `GET /api/costs/agent`, every 60 s with an `AbortController` ref. |
|
|
53
|
+
| Agent costs | `app/agents/CostSection.tsx` | The COSTS section of the agent panel, after MODEL: the rates in force now (`Rates now`: DeepSeek peak / off-peak with the UTC time of the next change, when a scheduled model is on offer — the same object as the Costs page header), today (UTC), last 7 and 30 days with tokens, the top model, calls without a price, when the agent was last read (a note when not recently: a stopped agent keeps its last figures), and a link to `/costs`. From `GET /api/costs/agent`, every 60 s with an `AbortController` ref. |
|
|
54
54
|
| Agent resources | `app/agents/ResourceSection.tsx` | The RESOURCES section of the agent panel, after COSTS: CPU and memory now and over the last 24 h, processes, network, storage (volume and layer), from `GET /api/metrics/containers`, every 60 s with an `AbortController` ref, reset on agent switch; *Stopped* when the agent is not running, *Not collecting* when the newest sample is older than three intervals (the "now" figures then stand down), a refresh-failure line while the last answer stays, and a link to its charts on `/system?agent=<container>` — the history lives in one place. Rows are the shared `PanelRow` (`app/agents/PanelRow.tsx`, also used by COSTS). |
|
|
55
55
|
| Containers page | `app/containers/ContainersClient.tsx` | `/containers`: one card per Docker container; a running one the daemon has sampled adds a line — CPU share, memory, PIDs — and *Charts →* to its accordion on `/system`. The figures are a bonus: a daemon that has not sampled yet, or a failed request, leaves the list as it was. |
|
|
56
56
|
| Machine card | `app/components/SystemCockpit.tsx` | On the dashboard, next to Active sessions: CPU, RAM and the fullest disk now, each figure coloured only when past its threshold (same thresholds as the System page), the issues named in words, and a link to `/system`. It says *No samples yet* before the first sample, *Not collecting* when the newest is stale, and *Metrics unavailable* when a refresh fails (keeping the last figures). A `Surface`, not a `Metric`: a danger `Metric` paints its whole value red. |
|
package/docs/REV4A.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Rev4a — VPS Dashboard
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-10-
|
|
3
|
+
> **Last updated:** 2026-10-02
|
|
4
4
|
|
|
5
5
|
A Next.js 16 dashboard for monitoring and managing the OpenClaw ecosystem.
|
|
6
6
|
|
|
@@ -56,7 +56,7 @@ Rev4a reads the following environment variables. Set them in `/config` (UI) or d
|
|
|
56
56
|
| `REV4A_AGENT_IMAGE_REGISTRY` | No | Registry repository agent images are pulled from (default: `ghcr.io/flame0510/rev4a/openclaw-agent-base`); a `localhost` registry is reached over plain HTTP |
|
|
57
57
|
| `REV4A_DOCKER_WAIT_SECONDS` | No | How long `rev4a serve` waits for the Docker daemon at start-up (default `90`, capped at 600) |
|
|
58
58
|
| `REV4A_DEV_ORIGINS` | No | Extra comma-separated hostnames or IPs allowed to reach `next dev`; no effect on a production build |
|
|
59
|
-
| `REV4A_SKIP_POSTINSTALL_BUILD` | No | `1` in CI: `npm ci` then skips the post-install `next build` (the workflows build as a separate step). Any non-empty value skips
|
|
59
|
+
| `REV4A_SKIP_POSTINSTALL_BUILD` | No | `1` in CI: `npm ci` then skips the post-install `next build` (the workflows build as a separate step). An installation build failure exits non-zero; startup checks `.next/BUILD_ID` before serving. Any non-empty value skips the post-install build, `0` included — leave it unset to build |
|
|
60
60
|
|
|
61
61
|
See [Alerts](#alerts-telegram) below for the Telegram alert variables.
|
|
62
62
|
|
|
@@ -198,7 +198,7 @@ that too; run Sync All Agents once Docker is up.
|
|
|
198
198
|
### Memory (a host that runs several agents)
|
|
199
199
|
|
|
200
200
|
`rev4a update` installs the new version and the server rebuilds Next before it comes
|
|
201
|
-
back (
|
|
201
|
+
back (the install's postinstall build, or a startup build when none exists). That build is the memory peak
|
|
202
202
|
of the whole installation: with several agent Gateways resident (roughly 0.5-0.8 GB
|
|
203
203
|
each) a host with no swap can hit the kernel OOM killer during the update — it killed
|
|
204
204
|
`next-server` on a 7.7 GB VPS, which then stayed unreachable until a reboot.
|
|
@@ -450,6 +450,9 @@ repo. The copy is skipped when the workspace is already populated (see
|
|
|
450
450
|
One job: provider configuration. API keys, which catalogue models this deployment
|
|
451
451
|
offers, and pushing that catalogue to every agent.
|
|
452
452
|
|
|
453
|
+
For DeepSeek models, each model row shows the current peak/off-peak band and its
|
|
454
|
+
current rate. An open page refreshes those figures after the next band change.
|
|
455
|
+
|
|
453
456
|
- Reads the model catalogue from `models.config.json`, merged with user toggles in
|
|
454
457
|
`<data dir>/model-overrides.json`
|
|
455
458
|
- Scans providers with keys in `<data dir>/provider-keys.json`
|