@flame0510/project-aether 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/agent-templates/atlas/HEARTBEAT.md +1 -1
  4. package/app/agents/ImageDownloadBanner.tsx +171 -37
  5. package/app/api/agents/download-image/route.ts +29 -5
  6. package/app/api/agents/image-status/route.ts +17 -2
  7. package/app/api/assistant/route.ts +21 -5
  8. package/app/api/auth/login/route.ts +2 -2
  9. package/app/api/metrics/route.ts +126 -23
  10. package/app/api/setup/agent-image/route.ts +6 -4
  11. package/app/api/system-health/route.ts +25 -21
  12. package/app/components/DashboardToolbar.tsx +2 -2
  13. package/app/components/LineageGraphPage.tsx +4 -4
  14. package/app/components/SessionDrawer.tsx +8 -8
  15. package/app/components/Sidebar.tsx +10 -0
  16. package/app/components/Skeleton.tsx +4 -1
  17. package/app/components/SystemCockpit.tsx +83 -3
  18. package/app/components/ui/Meter.tsx +34 -0
  19. package/app/components/ui/TimeSeriesChart.tsx +226 -0
  20. package/app/components/ui/index.ts +2 -0
  21. package/app/globals.css +42 -1
  22. package/app/setup/PageClient.tsx +1 -1
  23. package/app/system/PageClient.tsx +263 -0
  24. package/app/system/SystemSkeleton.tsx +115 -0
  25. package/app/system/loading.tsx +13 -0
  26. package/app/system/page.tsx +5 -0
  27. package/bin/postinstall.js +5 -1
  28. package/daemon.js +274 -214
  29. package/docs/ARCHITECTURE.md +65 -34
  30. package/docs/CONTAINER-TERMINAL.md +17 -8
  31. package/docs/DESIGN-SYSTEM.md +22 -12
  32. package/docs/FRONTEND-ARCHITECTURE.md +8 -4
  33. package/docs/REV4A.md +37 -86
  34. package/docs/dev/API-REFERENCE.md +102 -19
  35. package/docs/dev/DATABASE.md +79 -28
  36. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
  37. package/docs/rag/DATA-FRESHNESS.md +31 -15
  38. package/docs/rag/GLOSSARY.md +8 -5
  39. package/docs/rag/REV4A-OVERVIEW.md +14 -7
  40. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -3
  41. package/lib/agent-images.ts +43 -14
  42. package/lib/buildAgentImage.ts +142 -6
  43. package/lib/db-bootstrap.mjs +0 -11
  44. package/lib/metrics-db.ts +48 -0
  45. package/lib/patterns/sessionPresentation.ts +4 -2
  46. package/lib/rev4a-auth.d.ts +1 -0
  47. package/lib/rev4a-auth.js +18 -2
  48. package/next.config.mjs +9 -1
  49. package/package.json +2 -2
  50. package/scripts/backup.sh +48 -54
  51. package/scripts/check-language.mjs +21 -3
  52. package/scripts/restore.sh +77 -59
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michele Tornello
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -13,11 +13,13 @@
13
13
  - **Agent creation wizard** — Pick a template (Atlas, Prometheus, Argus), choose an LLM model, deploy an agent in seconds
14
14
  - **Provider sync** — Add API keys once, push them to all agent containers with one click
15
15
  - **Model configuration** — Change the primary model and fallbacks per agent, from the detail panel on the Agents page. Providers: OpenAI, Anthropic, Google, DeepSeek, Groq, Kimi, GLM, Qwen, plus OpenRouter as an aggregator.
16
+ - **Model details** — Every model row opens a modal with its description, price and provider, architecture and parameter count (mixture of experts, context, vision encoder, precision, weights on disk), reasoning modes, capabilities, benchmarks with sources and licence. Numbers come from the model card on Hugging Face plus curated entries that cite a source; nothing is estimated
16
17
  - **Shared gateway token** — One token to rule all agents. Manage from the dashboard, sync to all containers
17
18
  - **Live event stream** — Real-time session lineage, agent spawning, cost reporting
18
19
  - **Workspace browser** — Browse and edit agent files live from the dashboard
19
20
  - **Provider proxy** — OpenAI-compatible chat completions proxy that routes through a shared key vault
20
- - **System health** — CPU, RAM, disk, Docker daemon status, recommendations
21
+ - **System health** — runtime sessions, daemon heartbeat and errors, cron jobs and usage cost, 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)
21
23
  - **Cost tracking** — Per-session, per-model, per-time-range usage and cost reporting
22
24
  - **PWA-ready** — Install on phone for mobile monitoring
23
25
 
@@ -81,6 +83,8 @@ already filled in — you normally never set these by hand. See
81
83
  | `REV4A_DATA_DIR` | `~/.config/rev4a` | No | Root of all persistent data |
82
84
  | `REV4A_DB` | `<data dir>/data/events.db` | No | SQLite database path |
83
85
  | `REV4A_WORKSPACE_ROOT` | — | No | Root exposed by the local file explorer |
86
+ | `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 |
84
88
  | `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 |
85
89
  | `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 |
86
90
 
@@ -161,4 +165,4 @@ at container creation time.
161
165
 
162
166
  ## License
163
167
 
164
- MIT
168
+ MIT — see [LICENSE](./LICENSE).
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Operational status
4
4
  - **Role:** Developer Lead
5
- - **Workspace:** /data/.openclaw/workspace-atlas
5
+ - **Workspace:** /root/.openclaw/workspace/
6
6
 
7
7
  ## Periodic checks (heartbeat)
8
8
  - [ ] Any active tasks from Argus?
@@ -7,6 +7,19 @@
7
7
  * the registry publishes a newer supported version, and while a download runs.
8
8
  * Downloading touches no agent: each agent keeps its OpenClaw version until it is
9
9
  * updated on its own.
10
+ *
11
+ * The percent and the status line are real (docker pull's own per-layer lines, via
12
+ * GET /api/agents/image-status — see lib/buildAgentImage.ts); the bar is indeterminate
13
+ * only while no percent can be computed yet (resolving the version, listing the layers,
14
+ * the local-build fallback). Cancel (DELETE /api/agents/download-image) appears once the
15
+ * server reports the download running, and kills the docker process.
16
+ *
17
+ * Every outcome — ready, cancelled, failed — comes from the server's record of the last
18
+ * download (`lastResult`), whether this instance watched it happen or not: a download
19
+ * that ended while the Agents page was closed or reloading is still reported, if it
20
+ * ended less than LATE_RESULT_WINDOW_MS ago (aged with the server's clock). Each outcome
21
+ * is shown once per page load, even though the Agents page renders this component in
22
+ * more than one place (mobile list, mobile detail, desktop).
10
23
  */
11
24
  import { useState, useEffect, useCallback, useRef } from 'react';
12
25
  import { Button } from '../components/ui';
@@ -16,6 +29,21 @@ interface ImageStatus {
16
29
  needsUpdate: boolean;
17
30
  downloading: boolean;
18
31
  downloadingVersion: string | null;
32
+ /** Real percent from docker pull's per-layer lines; null while it cannot be computed yet. */
33
+ downloadPercent: number | null;
34
+ /** The latest line of docker's output, shown under the percent. */
35
+ downloadMessage: string | null;
36
+ /** Whether the last finished download ended as cancelled. */
37
+ canceled: boolean;
38
+ /** The server's clock when it answered, used to age `lastResult.at`. */
39
+ serverTime?: number;
40
+ /** The last download's outcome, even one this component instance never watched live. */
41
+ lastResult: {
42
+ version: string;
43
+ result: 'succeeded' | 'failed' | 'canceled';
44
+ message: string;
45
+ at: number;
46
+ } | null;
19
47
  localVersions: string[];
20
48
  newestLocal: string | null;
21
49
  /** Newest supported version on the registry that is not downloaded yet. */
@@ -23,16 +51,34 @@ interface ImageStatus {
23
51
  }
24
52
 
25
53
  const POLL_MS = 2000;
54
+ /** How long a late-arriving result (this component instance missed the live transition)
55
+ * is still worth showing. Long enough to cover switching screens and coming right back;
56
+ * short enough that reopening the page an hour later shows nothing stale. */
57
+ const LATE_RESULT_WINDOW_MS = 30_000;
58
+
59
+ /**
60
+ * The newest `lastResult.at` already shown or dismissed. Module-level rather than per
61
+ * instance: the Agents page mounts this banner in several places and remounts it when the
62
+ * mobile view switches, and one outcome must not be announced once per mount. A page
63
+ * reload resets it, which is intended — that is one of the cases it exists to cover.
64
+ */
65
+ let lastShownResultAt = 0;
26
66
 
27
67
  export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: () => void }) {
28
68
  const [status, setStatus] = useState<ImageStatus>({
29
- exists: true, needsUpdate: false, downloading: false, downloadingVersion: null, localVersions: [], newestLocal: null, available: null,
69
+ exists: true, needsUpdate: false, downloading: false, downloadingVersion: null,
70
+ downloadPercent: null, downloadMessage: null, canceled: false, lastResult: null,
71
+ localVersions: [], newestLocal: null, available: null,
30
72
  });
31
73
  const [error, setError] = useState<string | null>(null);
32
74
  const [justCompleted, setJustCompleted] = useState<string | null>(null);
75
+ const [justCanceled, setJustCanceled] = useState(false);
33
76
  const [actionLoading, setActionLoading] = useState(false);
77
+ const [canceling, setCanceling] = useState(false);
78
+ const [cancelError, setCancelError] = useState<string | null>(null);
34
79
  const wasDownloadingRef = useRef<string | null>(null);
35
80
  const completedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
81
+ const canceledTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
36
82
 
37
83
  useEffect(() => {
38
84
  let active = true;
@@ -45,21 +91,48 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
45
91
  const data: ImageStatus = await res.json();
46
92
  setStatus(data);
47
93
 
94
+ const result = data.lastResult;
95
+ const unseen = !!result && result.at > lastShownResultAt;
96
+ const showOutcome = (r: NonNullable<ImageStatus['lastResult']>) => {
97
+ lastShownResultAt = r.at;
98
+ if (r.result === 'canceled') {
99
+ setJustCanceled(true);
100
+ if (canceledTimerRef.current) clearTimeout(canceledTimerRef.current);
101
+ canceledTimerRef.current = setTimeout(() => setJustCanceled(false), 3000);
102
+ } else if (r.result === 'succeeded') {
103
+ setJustCompleted(r.version);
104
+ onImageReady?.();
105
+ if (completedTimerRef.current) clearTimeout(completedTimerRef.current);
106
+ completedTimerRef.current = setTimeout(() => setJustCompleted(null), 3000);
107
+ } else {
108
+ setError(r.message || `The OpenClaw ${r.version} download did not complete.`);
109
+ }
110
+ };
111
+
48
112
  if (data.downloading) {
49
113
  setError(null);
50
114
  wasDownloadingRef.current = data.downloadingVersion ?? wasDownloadingRef.current ?? 'image';
115
+ // The record on file is from before this run: not news any more.
116
+ if (result) lastShownResultAt = Math.max(lastShownResultAt, result.at);
51
117
  } else if (wasDownloadingRef.current) {
118
+ // Live transition: this instance watched the download run and stop.
52
119
  const finished = wasDownloadingRef.current;
53
120
  wasDownloadingRef.current = null;
54
121
  setActionLoading(false);
55
- if (data.localVersions?.includes(finished)) {
56
- setJustCompleted(finished);
57
- onImageReady?.();
58
- if (completedTimerRef.current) clearTimeout(completedTimerRef.current);
59
- completedTimerRef.current = setTimeout(() => setJustCompleted(null), 3000);
60
- } else {
122
+ setCanceling(false);
123
+ setCancelError(null);
124
+ if (result && unseen) showOutcome(result);
125
+ else if (!result) {
61
126
  setError(`The OpenClaw ${finished} download did not complete. Check the Docker connection and try again.`);
62
127
  }
128
+ // result present but already seen: another instance (or a late pass) announced it.
129
+ } else if (result && unseen && (data.serverTime ?? Date.now()) - result.at < LATE_RESULT_WINDOW_MS) {
130
+ // Arrived late: it ended while this instance was not mounted or not yet
131
+ // watching (a reload, a return from another page, a very fast download).
132
+ showOutcome(result);
133
+ } else if (result) {
134
+ // Already shown, or too old to matter: just remember it so it cannot retrigger.
135
+ lastShownResultAt = Math.max(lastShownResultAt, result.at);
63
136
  }
64
137
  } catch { /* ignore */ }
65
138
 
@@ -73,6 +146,7 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
73
146
  useEffect(() => {
74
147
  return () => {
75
148
  if (completedTimerRef.current) clearTimeout(completedTimerRef.current);
149
+ if (canceledTimerRef.current) clearTimeout(canceledTimerRef.current);
76
150
  };
77
151
  }, []);
78
152
 
@@ -99,39 +173,83 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
99
173
  }
100
174
  }, []);
101
175
 
176
+ const handleCancel = useCallback(async () => {
177
+ setCanceling(true);
178
+ setCancelError(null);
179
+ try {
180
+ const res = await fetch('/api/agents/download-image', { method: 'DELETE' });
181
+ if (res.status === 404) {
182
+ // Nothing running any more: it ended on its own; its outcome arrives with the poll.
183
+ setCanceling(false);
184
+ return;
185
+ }
186
+ if (!res.ok) {
187
+ const data = await res.json().catch(() => ({ error: 'Failed to cancel' }));
188
+ setCancelError(data.error || 'Failed to cancel');
189
+ setCanceling(false);
190
+ }
191
+ // 202: the poll clears `canceling` once the download has actually stopped.
192
+ } catch (e: unknown) {
193
+ setCancelError((e as Error).message || 'Failed to cancel');
194
+ setCanceling(false);
195
+ }
196
+ }, []);
197
+
102
198
  const isDownloading = status.downloading || actionLoading;
103
- const showBanner = status.needsUpdate || isDownloading || justCompleted || error;
199
+ const showBanner = status.needsUpdate || isDownloading || justCompleted || justCanceled || error;
104
200
 
105
201
  if (!showBanner && status.exists && !isDownloading) return null;
106
202
 
107
203
  // ─── Downloading ───
108
204
  if (isDownloading) {
109
205
  const version = status.downloadingVersion ?? status.available;
206
+ const percent = status.downloadPercent;
110
207
  return (
111
208
  <Banner downloading>
112
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
113
- <span style={{
114
- display: 'inline-block', width: 8, height: 8,
115
- borderRadius: '50%',
116
- background: 'var(--violet)',
117
- animation: 'pulse 1.5s ease-in-out infinite',
118
- flexShrink: 0,
119
- }} />
120
- <span style={{ color: 'var(--violet)', fontWeight: 500 }}>
121
- {version ? `Downloading the OpenClaw ${version} agent image…` : 'Downloading the agent image…'}
122
- </span>
209
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }}>
210
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
211
+ <span style={{
212
+ display: 'inline-block', width: 8, height: 8,
213
+ borderRadius: '50%',
214
+ background: 'var(--violet)',
215
+ animation: 'pulse 1.5s ease-in-out infinite',
216
+ flexShrink: 0,
217
+ }} />
218
+ <span style={{ color: 'var(--violet)', fontWeight: 500 }}>
219
+ {version ? `Downloading the OpenClaw ${version} agent image…` : 'Downloading the agent image…'}
220
+ {percent !== null ? ` ${percent}%` : ''}
221
+ </span>
222
+ </div>
223
+ {status.downloadMessage && (
224
+ <span style={{ color: 'var(--text-dim)', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
225
+ {status.downloadMessage}
226
+ </span>
227
+ )}
228
+ {cancelError && <span style={{ color: 'var(--red)', fontSize: 11 }}>{cancelError}</span>}
123
229
  </div>
124
230
  <div style={{
125
231
  flex: 1, maxWidth: 120, height: 3,
126
- background: 'var(--border)', borderRadius: 2, overflow: 'hidden',
232
+ background: 'var(--border)', borderRadius: 2, overflow: 'hidden', flexShrink: 0,
127
233
  }}>
128
- <div style={{
129
- height: 3, background: 'var(--violet)',
130
- animation: 'progress-indeterminate 1.5s ease-in-out infinite',
131
- width: '40%', borderRadius: 2,
132
- }} />
234
+ {percent !== null ? (
235
+ <div style={{ height: 3, background: 'var(--violet)', width: `${percent}%`, borderRadius: 2 }} />
236
+ ) : (
237
+ <div style={{
238
+ height: 3, background: 'var(--violet)',
239
+ animation: 'progress-indeterminate 1.5s ease-in-out infinite',
240
+ width: '40%', borderRadius: 2,
241
+ }} />
242
+ )}
133
243
  </div>
134
- <Button variant="secondary" size="sm" disabled>Downloading…</Button>
244
+ {/* Offered only once the server reports the download running: before that there
245
+ is nothing to cancel, and a DELETE would answer 404. */}
246
+ {status.downloading ? (
247
+ <Button variant="secondary" size="sm" onClick={() => { void handleCancel(); }} loading={canceling} disabled={canceling}>
248
+ {canceling ? 'Canceling…' : 'Cancel'}
249
+ </Button>
250
+ ) : (
251
+ <Button variant="secondary" size="sm" disabled>Starting…</Button>
252
+ )}
135
253
  </Banner>
136
254
  );
137
255
  }
@@ -141,25 +259,41 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
141
259
  return (
142
260
  <Banner success>
143
261
  <div style={{ flex: 1 }}>
144
- <span style={{ color: '#22c55e', fontWeight: 500 }}>✓ OpenClaw {justCompleted} agent image ready</span>
262
+ <span style={{ color: 'var(--green)', fontWeight: 500 }}>✓ OpenClaw {justCompleted} agent image ready</span>
263
+ </div>
264
+ </Banner>
265
+ );
266
+ }
267
+
268
+ // ─── Just canceled ───
269
+ if (justCanceled) {
270
+ return (
271
+ <Banner>
272
+ <div style={{ flex: 1 }}>
273
+ <span style={{ color: 'var(--text-dim)', fontWeight: 500 }}>Download canceled.</span>
145
274
  </div>
146
275
  </Banner>
147
276
  );
148
277
  }
149
278
 
150
- // ─── Needs download ───
279
+ // ─── Needs download, or an error to report ───
280
+ // With an image present and nothing newer published, only an error brings us here:
281
+ // show just the error, not an "available" line with no version to name.
282
+ const headline = !status.exists
283
+ ? '⚠ Agent base image not found.'
284
+ : status.available ? `⚠ OpenClaw ${status.available} is available.` : null;
151
285
  return (
152
286
  <Banner warning>
153
287
  <div style={{ flex: 1 }}>
154
- <strong style={{ color: '#fbbf24' }}>
155
- {status.exists
156
- ? `⚠ OpenClaw ${status.available} is available.`
157
- : '⚠ Agent base image not found.'}
158
- </strong>
159
- {' '}{status.exists
160
- ? 'Download it for new agents; existing agents keep their version until they are updated.'
161
- : 'Download it to create agent containers.'}
162
- {error && <div style={{ color: '#ef4444', fontSize: 11, marginTop: 4 }}>{error}</div>}
288
+ {headline && (
289
+ <>
290
+ <strong style={{ color: 'var(--yellow)' }}>{headline}</strong>
291
+ {' '}{status.exists
292
+ ? 'Download it for new agents; existing agents keep their version until they are updated.'
293
+ : 'Download it to create agent containers.'}
294
+ </>
295
+ )}
296
+ {error && <div style={{ color: 'var(--red)', fontSize: headline ? 11 : 12, marginTop: headline ? 4 : 0 }}>{headline ? '' : '⚠ '}{error}</div>}
163
297
  </div>
164
298
  {(status.exists ? status.available : true) && (
165
299
  <Button variant="primary" size="sm" onClick={() => { void handleDownload(status.available); }} loading={actionLoading}>
@@ -5,14 +5,21 @@ import { type NextRequest } from "next/server";
5
5
  * Downloads one OpenClaw version of the agent base image in the background.
6
6
  * Body (optional): `{ "version": "2026.9.3" }` — a supported version; without it, the
7
7
  * newest supported version the registry publishes. Returns 202 with the version;
8
- * poll GET /api/agents/image-status for completion.
8
+ * poll GET /api/agents/image-status for completion, percent and the latest status line.
9
9
  *
10
- * Requires auth. 400 for an unsupported version, 409 if a download is already running.
10
+ * Requires auth. 400 for an unsupported version, 409 if a download is already running,
11
+ * 503 when Docker is not available.
12
+ *
13
+ * DELETE /api/agents/download-image
14
+ *
15
+ * Cancels the running download, if any (kills the docker pull/build process).
16
+ * Requires auth. 404 when nothing is running.
11
17
  */
12
18
  import { NextResponse } from 'next/server';
13
- import { defaultDownloadVersion, downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
19
+ import { cancelDownload, defaultDownloadVersion, downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
14
20
  import { isSupportedVersion } from '@/lib/agent-versions';
15
21
  import { requireAuthJWT } from '@/lib/rev4a-auth';
22
+ import { dockerAvailable } from '@/lib/docker-socket';
16
23
 
17
24
  export const dynamic = 'force-dynamic';
18
25
 
@@ -39,11 +46,28 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
39
46
  if (getIsDownloading()) {
40
47
  return NextResponse.json({ error: 'A download is already in progress' }, { status: 409 });
41
48
  }
49
+ // Refused here rather than inside downloadAgentImage: that check throws before any
50
+ // outcome is recorded, so the banner would get a 202 and then nothing to explain it.
51
+ if (!dockerAvailable()) {
52
+ return NextResponse.json({ error: 'Docker is not available — start Docker and try again' }, { status: 503 });
53
+ }
42
54
 
43
- // Start download in background — fire and forget
55
+ // Start download in background — fire and forget. A cancel (DELETE below) makes this
56
+ // reject with 'canceled', not a real failure: skip the log.
44
57
  downloadAgentImage({ version }).catch((e: unknown) => {
45
- console.error(`[download-image] OpenClaw ${version}:`, (e as Error).message);
58
+ if ((e as Error).message !== 'canceled') {
59
+ console.error(`[download-image] OpenClaw ${version}:`, (e as Error).message);
60
+ }
46
61
  });
47
62
 
48
63
  return NextResponse.json({ started: true, version }, { status: 202 });
49
64
  }
65
+
66
+ export async function DELETE(request: NextRequest): Promise<NextResponse> {
67
+ const denied = await requireAuthJWT(request);
68
+ if (denied) return denied;
69
+ if (!cancelDownload()) {
70
+ return NextResponse.json({ error: 'No download is running' }, { status: 404 });
71
+ }
72
+ return NextResponse.json({ canceling: true }, { status: 202 });
73
+ }
@@ -9,7 +9,7 @@
9
9
  * (lib/agent-images.ts), so polling does not hit the network.
10
10
  */
11
11
  import { NextResponse, type NextRequest } from 'next/server';
12
- import { getDownloadingVersion, getIsDownloading } from '@/lib/buildAgentImage';
12
+ import { getDownloadProgress, getDownloadingVersion, getIsDownloading, getLastDownloadResult, wasDownloadCanceled } from '@/lib/buildAgentImage';
13
13
  import { requireAuthJWT } from '@/lib/rev4a-auth';
14
14
  import { dockerAvailable } from '@/lib/docker-socket';
15
15
  import { listLocalAgentImages, localSupportedVersions, remoteSupportedVersions, type LocalAgentImage } from '@/lib/agent-images';
@@ -21,7 +21,22 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
21
21
  const denied = await requireAuthJWT(request);
22
22
  if (denied) return denied;
23
23
 
24
- const base = { downloading: getIsDownloading(), downloadingVersion: getDownloadingVersion() };
24
+ const progress = getDownloadProgress();
25
+ const base = {
26
+ downloading: getIsDownloading(),
27
+ downloadingVersion: getDownloadingVersion(),
28
+ downloadPercent: progress.percent,
29
+ downloadMessage: progress.message,
30
+ /** Whether the last finished download ended as cancelled (derived from `lastResult`). */
31
+ canceled: wasDownloadCanceled(),
32
+ /**
33
+ * The last download's outcome, whether or not anyone was watching it finish —
34
+ * covers a page reload or a late-opened tab. Overwritten by the next download.
35
+ */
36
+ lastResult: getLastDownloadResult(),
37
+ /** The server's clock, so the client can age `lastResult.at` without trusting its own. */
38
+ serverTime: Date.now(),
39
+ };
25
40
 
26
41
  if (!dockerAvailable()) {
27
42
  return NextResponse.json({
@@ -18,13 +18,13 @@ interface PageContext {
18
18
  const PAGE_CONTEXT: Record<string, PageContext> = {
19
19
  '/': {
20
20
  title: 'Dashboard',
21
- description: 'Main overview with live session list, cost summary by model, health cards for Rev4a\'s own runtime, cron and lineage, and a real-time event feed. It shows no CPU, RAM, disk or load average: those are collected by the daemon but no page displays them.',
21
+ description: 'Main overview with live session list, cost summary by model, health cards for Rev4a\'s own runtime, cron and lineage, and a real-time event feed. The Machine card shows the server\'s CPU, RAM and fullest disk right now, coloured past a threshold, with a link to the System page.',
22
22
  actions: ['Click any session row to open the Session Drawer for full details and tool call history', 'View cost breakdown by model and time period', 'See live events as they happen', 'Take a screenshot of the dashboard (mobile)'],
23
23
  hints: 'The Session Drawer shows you the complete tool call history for any session — just click a row.',
24
24
  },
25
25
  '/agents': {
26
26
  title: 'Agents',
27
- description: 'List of all Docker containers that run OpenClaw agents. Shows status (running/exited/paused), image, ports, IP, and creation time. Includes a top banner for the agent base image status: yellow warning when outdated or missing, animated progress bar when downloading from registry.',
27
+ description: 'List of all Docker containers that run OpenClaw agents. Shows status (running/exited/paused), image, ports, IP, and creation time. Includes a top banner for the agent base image status: yellow warning when a newer version is published or none is downloaded; while downloading, the percent of layers finished, docker\'s latest output line and a Cancel button; afterwards the outcome (ready, canceled or the error).',
28
28
  actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, backups, Telegram channels, and the Model section that sets its primary model and fallbacks)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
29
29
  hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when a newer supported version is published on the registry — downloading makes it available to create and update agents on; it changes no agent by itself.',
30
30
  },
@@ -46,6 +46,12 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
46
46
  actions: ['See all scheduled cron jobs at a glance', 'Check when each cron last ran and when it will run next', 'View job status (scheduled, running, completed, error)'],
47
47
  hints: 'Cron jobs use standard cron syntax. If a job shows "error" status, it means the last execution failed.',
48
48
  },
49
+ '/system': {
50
+ title: 'System',
51
+ 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.',
52
+ 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'],
53
+ 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.',
54
+ },
49
55
  '/containers': {
50
56
  title: 'Containers',
51
57
  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.',
@@ -58,11 +64,17 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
58
64
  actions: ['Run shell commands inside the container', 'Inspect files, processes, and logs live', 'Use any CLI tool available inside the container'],
59
65
  hints: 'This is a live terminal — changes you make inside the container are real. Be careful with destructive commands.',
60
66
  },
67
+ '/credentials': {
68
+ title: 'Credentials',
69
+ description: 'Store third-party service credentials (GitHub, Vercel, Supabase, Trello, Notion) once and install them into agent containers. Secrets are stored unencrypted in credentials.db; provider API keys are managed on the Gateway page, not here.',
70
+ actions: ['Configure a provider, or "+ Add another" credential for one already configured', 'Edit or delete a stored credential', 'Sync a credential to an agent container, or De-sync it to remove it', 'See which credentials are actually installed in each container (detected live)'],
71
+ hints: 'A credential reaches a container only when you sync it, and leaves only when you de-sync it. Any stored credential can be synced to any container.',
72
+ },
61
73
  '/config': {
62
74
  title: 'Config',
63
75
  description: 'Rev4a application configuration page. View and manage settings that control how Rev4a behaves.',
64
76
  actions: ['View current configuration values', 'Modify configuration settings', 'Save changes to update Rev4a behavior'],
65
- hints: 'Configuration changes take effect immediately when saved.',
77
+ hints: 'Saved values are written to the .env file; restart Rev4a for them to take effect.',
66
78
  },
67
79
  '/lineage': {
68
80
  title: 'Lineage',
@@ -107,10 +119,11 @@ Navigation links inside Rev4a:
107
119
  - Dashboard → /
108
120
  - Agents → /agents
109
121
  - Create Agent → /agents/create
110
- - Providers → /providers
122
+ - Credentials → /credentials
111
123
  - Gateway → /gateway
112
124
  - Cron Jobs → /crons
113
125
  - Containers → /containers
126
+ - System (CPU, RAM, disk of the server) → /system
114
127
  - Config → /config
115
128
  - Lineage → /lineage?period=7d
116
129
  - Memory / Context → /memory
@@ -118,11 +131,14 @@ Navigation links inside Rev4a:
118
131
  - Tools → /tools
119
132
  - Plugins → /plugins
120
133
  - Skills → /skills
134
+ - Wizard (first-run guide: providers, first agent) → /wizard
121
135
  `.trim();
122
136
 
123
137
  function buildSystemPrompt(page: string): string {
124
138
  const path = page.split('?')[0] || '/';
125
- const ctx = PAGE_CONTEXT[path] ?? {
139
+ // Dynamic routes are keyed by their pattern; the terminal is the only one.
140
+ const key = path.startsWith('/containers/terminal/') ? '/containers/terminal/[id]' : path;
141
+ const ctx = PAGE_CONTEXT[key] ?? {
126
142
  title: path,
127
143
  description: 'Unknown page.',
128
144
  actions: [],
@@ -1,6 +1,6 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { SignJWT } from 'jose';
3
- import { getPassword, getJwtSecret } from '@/lib/rev4a-auth';
3
+ import { getPassword, getJwtSecret, secretEquals } from '@/lib/rev4a-auth';
4
4
 
5
5
  export async function POST(request: NextRequest): Promise<NextResponse> {
6
6
  try {
@@ -27,7 +27,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
27
27
  password?: string;
28
28
  };
29
29
 
30
- if (!input || input !== password) {
30
+ if (!input || !secretEquals(input, password)) {
31
31
  return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
32
32
  }
33
33