@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 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 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(
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
- 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
  /**
@@ -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({
@@ -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
- ...(view.price ? [{ label: 'Per 1M tokens, from', value: view.providerLabel, dim: true }] : []),
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
- setLoading(true);
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
- // Load keys from provider response (all configured providers now return apiKey)
123
- const keyEntries: Record<string, string> = {};
124
- const origEntries: Record<string, string> = {};
125
- provJson.providers.forEach((p: any) => {
126
- if (p.configured && p.apiKey) {
127
- keyEntries[p.provider] = p.apiKey;
128
- origEntries[p.provider] = p.apiKey;
129
- }
130
- });
131
- setApiKeyInputs((prev) => ({ ...prev, ...keyEntries }));
132
- setOriginalKeys((prev) => ({ ...prev, ...origEntries }));
133
-
134
- // Auto-fetch balances for supported providers that have keys configured
135
- const balanceProvs = provJson.providers
136
- .filter((p: any) => p.configured && ['deepseek','openrouter','glm'].includes(p.provider))
137
- .map((p: any) => p.provider);
138
- for (const bp of balanceProvs) {
139
- try {
140
- const bRes = await fetch(`/api/gateway/provider/balance?provider=${bp}`);
141
- const bJson = await bRes.json();
142
- if (bRes.ok && bJson.balance) {
143
- setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
144
- } else if (bJson.reason === 'no-balance-api') {
145
- // Permanent: this provider has no balance API for the account — hide the badge.
146
- setBalances((prev) => ({ ...prev, [bp]: { balance: '', unsupported: true } }));
147
- } else {
148
- setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
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
- setError(e instanceof Error ? e.message : String(e));
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
- setLoading(false);
180
+ if (loadAbortRef.current === controller) {
181
+ loadAbortRef.current = null;
182
+ setLoading(false);
183
+ }
159
184
  }
160
185
  }, []);
161
186
 
162
- useEffect(() => { void loadAll(); }, [loadAll]);
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 }))}
@@ -1,24 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Post-install hook: build Next.js app if .next doesn't exist.
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 and swallow its own failure.
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 NEXT_DIR = join(ROOT, '.next');
14
+ const BUILD_ID = join(ROOT, '.next', 'BUILD_ID');
14
15
 
15
- if (!process.env.REV4A_SKIP_POSTINSTALL_BUILD && !existsSync(NEXT_DIR)) {
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
- // Wipe old build so rev4a serve rebuilds with the new version
773
- const nextDir = join(ROOT, '.next');
774
- if (existsSync(nextDir)) {
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-01
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-01
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 it, `0` included — leave it unset to 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). 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 (`rev4a` wipes `.next`, so the build always runs). That build is the memory peak
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`