@flame0510/project-aether 1.3.0 → 1.4.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.
- package/README.md +1 -0
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +4 -1
- package/app/agents/PageClient.tsx +629 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -163
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -150
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +32 -88
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +2 -2
- package/app/api/gateway/agent/route.ts +14 -0
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/sync.ts +97 -14
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/gateway/PageClient.tsx +27 -32
- package/bin/rev4a.js +43 -41
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +95 -9
- package/docs/FRONTEND-ARCHITECTURE.md +8 -1
- package/docs/REV4A.md +54 -17
- package/docs/dev/API-REFERENCE.md +554 -100
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +21 -6
- package/docs/rag/DATA-FRESHNESS.md +6 -4
- package/docs/rag/GLOSSARY.md +12 -3
- package/docs/rag/REV4A-OVERVIEW.md +18 -5
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
- package/instrumentation.ts +43 -0
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +157 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +27 -0
- package/lib/agent-ports.ts +68 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +305 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +135 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +448 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +149 -102
- package/lib/cold-backup.ts +354 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/provider-balance.ts +33 -12
- package/package.json +1 -1
|
@@ -8,6 +8,9 @@ import PasswordInput from '../components/PasswordInput';
|
|
|
8
8
|
import ImageDownloadBanner from './ImageDownloadBanner';
|
|
9
9
|
import ChannelManager from './ChannelManager';
|
|
10
10
|
import ModelSection from './ModelSection';
|
|
11
|
+
import BrowserAccessSection, { OpenControlUiButton } from './BrowserAccessSection';
|
|
12
|
+
import UpdateSection from './UpdateSection';
|
|
13
|
+
import { validatePortInput } from '@/lib/agent-ports';
|
|
11
14
|
import {
|
|
12
15
|
InfoIcon, BracesIcon, KeyIcon, TrashIcon, TerminalIcon,
|
|
13
16
|
LinkIcon, ArchiveIcon, RotateIcon, PackageIcon,
|
|
@@ -44,27 +47,10 @@ interface Agent {
|
|
|
44
47
|
created: string | null;
|
|
45
48
|
env: string[];
|
|
46
49
|
controlPort: string | null;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* URL of the agent's OpenClaw Control UI.
|
|
53
|
-
*
|
|
54
|
-
* Composed here rather than on the server on purpose. The hostname the dashboard
|
|
55
|
-
* is currently being reached on is, by definition, one that resolves and routes
|
|
56
|
-
* for this user — `localhost` on a dev machine, a LAN address from a phone, the
|
|
57
|
-
* VPS IP, or a domain. The server can only guess, and it guessed badly: it shelled
|
|
58
|
-
* out to `curl icanhazip.com` on every call and, on macOS, ended up returning the
|
|
59
|
-
* machine's `.local` mDNS name.
|
|
60
|
-
*
|
|
61
|
-
* Returns null when the agent has no published port (nothing to link to) or when
|
|
62
|
-
* called during a server render, where `window` doesn't exist.
|
|
63
|
-
*/
|
|
64
|
-
function agentControlUrl(agent: Agent): string | null {
|
|
65
|
-
if (!agent.controlPort || typeof window === 'undefined') return null;
|
|
66
|
-
const base = `http://${window.location.hostname}:${agent.controlPort}`;
|
|
67
|
-
return agent.authToken ? `${base}#token=${encodeURIComponent(agent.authToken)}` : base;
|
|
50
|
+
/** OpenClaw version the agent runs; null when it cannot be read (stopped agent on an unlabelled image). */
|
|
51
|
+
openclawVersion?: string | null;
|
|
52
|
+
/** A newer supported OpenClaw version is downloaded. */
|
|
53
|
+
updateAvailable?: boolean;
|
|
68
54
|
}
|
|
69
55
|
|
|
70
56
|
type FilterState = 'all' | 'running' | 'exited';
|
|
@@ -176,6 +162,9 @@ function AgentListItem({
|
|
|
176
162
|
selected,
|
|
177
163
|
channelSummary,
|
|
178
164
|
modelSummary,
|
|
165
|
+
deviceSummary,
|
|
166
|
+
backupSummary,
|
|
167
|
+
busy,
|
|
179
168
|
onClick,
|
|
180
169
|
onLifecycle,
|
|
181
170
|
}: {
|
|
@@ -184,6 +173,13 @@ function AgentListItem({
|
|
|
184
173
|
selected?: boolean;
|
|
185
174
|
channelSummary: Record<string, { telegram: boolean }>;
|
|
186
175
|
modelSummary: Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }>;
|
|
176
|
+
/** Browsers waiting for approval, per agent. */
|
|
177
|
+
deviceSummary: Record<string, { pending: number }>;
|
|
178
|
+
/** Cold backup currently running, per agent (chip on the card). */
|
|
179
|
+
/** The long operation in progress, per agent (chip on the card). */
|
|
180
|
+
backupSummary: Record<string, { kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit'; file?: string | null; version?: string | null; percent?: number | null }>;
|
|
181
|
+
/** Any long operation running on this agent: the lifecycle toggle is disabled. */
|
|
182
|
+
busy?: boolean;
|
|
187
183
|
onClick: () => void;
|
|
188
184
|
onLifecycle: () => void;
|
|
189
185
|
}) {
|
|
@@ -192,7 +188,7 @@ function AgentListItem({
|
|
|
192
188
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
193
189
|
<ToggleSwitch
|
|
194
190
|
checked={agent.state === 'running'}
|
|
195
|
-
disabled={loading || agent.state === 'unknown'}
|
|
191
|
+
disabled={loading || agent.state === 'unknown' || !!busy}
|
|
196
192
|
onChange={() => { void onLifecycle(); }}
|
|
197
193
|
ariaLabel={agent.state === 'running' ? 'Stop container' : 'Start container'}
|
|
198
194
|
/>
|
|
@@ -203,20 +199,7 @@ function AgentListItem({
|
|
|
203
199
|
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
204
200
|
{loading && <span style={{ fontSize: 10, color: '#888' }}>...</span>}
|
|
205
201
|
{agent.state === 'running' && agent.controlPort && (
|
|
206
|
-
<
|
|
207
|
-
variant="primary"
|
|
208
|
-
size="sm"
|
|
209
|
-
// Gate on the port, resolve the URL on click: the condition stays
|
|
210
|
-
// safe to evaluate during a server render, the URL never is.
|
|
211
|
-
onClick={(e) => {
|
|
212
|
-
e.stopPropagation();
|
|
213
|
-
const url = agentControlUrl(agent);
|
|
214
|
-
if (url) window.open(url, '_blank');
|
|
215
|
-
}}
|
|
216
|
-
title="Open agent Control UI"
|
|
217
|
-
>
|
|
218
|
-
Open ↗
|
|
219
|
-
</Button>
|
|
202
|
+
<OpenControlUiButton agentId={agent.agentId} stopPropagation />
|
|
220
203
|
)}
|
|
221
204
|
</div>
|
|
222
205
|
</div>
|
|
@@ -264,11 +247,37 @@ function AgentListItem({
|
|
|
264
247
|
<Badge tone="success"><span style={{ fontSize: 8, padding: '1px 6px' }}>TG</span></Badge>
|
|
265
248
|
</div>
|
|
266
249
|
)}
|
|
267
|
-
{agent.
|
|
250
|
+
{(deviceSummary[agent.agentId]?.pending ?? 0) > 0 && (
|
|
268
251
|
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
|
269
|
-
<Badge tone="warning"
|
|
252
|
+
<Badge tone="warning">
|
|
253
|
+
<span style={{ fontSize: 8, padding: '1px 6px' }}>
|
|
254
|
+
{deviceSummary[agent.agentId].pending === 1 ? 'BROWSER WAITING' : `${deviceSummary[agent.agentId].pending} BROWSERS WAITING`}
|
|
255
|
+
</span>
|
|
256
|
+
</Badge>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
{agent.updateAvailable && (
|
|
260
|
+
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
|
261
|
+
<Badge tone="warning"><span style={{ fontSize: 8, padding: '1px 6px' }}>UPDATE AVAILABLE</span></Badge>
|
|
270
262
|
</div>
|
|
271
263
|
)}
|
|
264
|
+
{(() => {
|
|
265
|
+
const b = backupSummary[agent.agentId];
|
|
266
|
+
if (!b) return null;
|
|
267
|
+
const label =
|
|
268
|
+
b.kind === 'backup' ? (b.percent !== null && b.percent !== undefined ? `BACKUP ${b.percent}%` : 'BACKUP…')
|
|
269
|
+
: b.kind === 'restore' ? 'RESTORING'
|
|
270
|
+
: b.kind === 'recreate' ? 'RECREATING'
|
|
271
|
+
: b.kind === 'edit' ? 'EDITING'
|
|
272
|
+
: 'UPDATING';
|
|
273
|
+
return (
|
|
274
|
+
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
|
275
|
+
<Badge tone="neutral">
|
|
276
|
+
<span style={{ fontSize: 8, padding: '1px 6px' }}>{label}</span>
|
|
277
|
+
</Badge>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
})()}
|
|
272
281
|
</ListItem>
|
|
273
282
|
);
|
|
274
283
|
}
|
|
@@ -284,6 +293,8 @@ export default function AgentsPageClient() {
|
|
|
284
293
|
const [showList, setShowList] = useState(true);
|
|
285
294
|
const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
|
|
286
295
|
const [mdSummary, setMdSummary] = useState<Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }>>({});
|
|
296
|
+
const [dvSummary, setDvSummary] = useState<Record<string, { pending: number }>>({});
|
|
297
|
+
const [bkSummary, setBkSummary] = useState<Record<string, { kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit'; file?: string | null; version?: string | null; percent?: number | null }>>({});
|
|
287
298
|
const [imageReady, setImageReady] = useState(true);
|
|
288
299
|
// null = not checked yet. Keeping "unknown" distinct from "none configured" is
|
|
289
300
|
// the whole point: `false` as the initial value makes an unanswered check look
|
|
@@ -298,6 +309,8 @@ export default function AgentsPageClient() {
|
|
|
298
309
|
/** Guards the un-awaited channel-summary fetch against out-of-order responses. */
|
|
299
310
|
const chSummarySeq = useRef(0);
|
|
300
311
|
const mdSummarySeq = useRef(0);
|
|
312
|
+
const dvSummarySeq = useRef(0);
|
|
313
|
+
const bkSummarySeq = useRef(0);
|
|
301
314
|
|
|
302
315
|
/**
|
|
303
316
|
* Refresh the per-agent model rows that feed the card chips.
|
|
@@ -319,6 +332,56 @@ export default function AgentsPageClient() {
|
|
|
319
332
|
setMdSummary(byId);
|
|
320
333
|
} catch { /* badges are optional */ }
|
|
321
334
|
}, []);
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Refresh the "browser waiting" badges. Shared by the poll and by an approve,
|
|
338
|
+
* reject or revoke in the detail panel, and sequence-guarded like the model rows.
|
|
339
|
+
*/
|
|
340
|
+
const refreshDeviceSummary = useCallback(async () => {
|
|
341
|
+
const seq = ++dvSummarySeq.current;
|
|
342
|
+
try {
|
|
343
|
+
const res = await fetch('/api/agents/devices-summary');
|
|
344
|
+
if (!res.ok || seq !== dvSummarySeq.current) return;
|
|
345
|
+
const payload = await res.json();
|
|
346
|
+
const byId: Record<string, { pending: number }> = {};
|
|
347
|
+
for (const row of payload.agents ?? []) byId[row.agentId] = { pending: row.pending ?? 0 };
|
|
348
|
+
setDvSummary(byId);
|
|
349
|
+
} catch { /* badges are optional */ }
|
|
350
|
+
}, []);
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Refresh the activity chips (`BACKUP nn%`, `RESTORING`, `RECREATING`, `EDITING`, `UPDATING`)
|
|
354
|
+
* from `/api/agents/activity-summary`. Sequence-guarded like the other summaries.
|
|
355
|
+
*/
|
|
356
|
+
const refreshBackupSummary = useCallback(async () => {
|
|
357
|
+
const seq = ++bkSummarySeq.current;
|
|
358
|
+
try {
|
|
359
|
+
const res = await fetch('/api/agents/activity-summary');
|
|
360
|
+
if (!res.ok || seq !== bkSummarySeq.current) return;
|
|
361
|
+
const payload = await res.json();
|
|
362
|
+
const byId: Record<string, { kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit'; file?: string | null; version?: string | null; percent?: number | null }> = {};
|
|
363
|
+
for (const row of payload.agents ?? []) byId[row.agentId] = row;
|
|
364
|
+
setBkSummary(byId);
|
|
365
|
+
} catch { /* badges are optional */ }
|
|
366
|
+
}, []);
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* A long job just started from the detail panel. Shows its chip at once instead of
|
|
370
|
+
* waiting for the next list poll, then lets `refreshBackupSummary()` confirm it.
|
|
371
|
+
*/
|
|
372
|
+
const notifyActivity = useCallback((agentId: string, kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit') => {
|
|
373
|
+
setBkSummary((prev) => ({ ...prev, [agentId]: { kind } }));
|
|
374
|
+
void refreshBackupSummary();
|
|
375
|
+
}, [refreshBackupSummary]);
|
|
376
|
+
|
|
377
|
+
// While any backup runs the chips follow the live percent at 2s; when none
|
|
378
|
+
// runs the dedicated timer stops and the summary only rides the 15s poll.
|
|
379
|
+
const anyBackupRunning = Object.keys(bkSummary).length > 0;
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
if (!anyBackupRunning) return;
|
|
382
|
+
const i = setInterval(() => { void refreshBackupSummary(); }, 2000);
|
|
383
|
+
return () => clearInterval(i);
|
|
384
|
+
}, [anyBackupRunning, refreshBackupSummary]);
|
|
322
385
|
useEffect(() => {
|
|
323
386
|
const check = () => setIsMobile(window.innerWidth < 768);
|
|
324
387
|
check();
|
|
@@ -355,6 +418,8 @@ export default function AgentsPageClient() {
|
|
|
355
418
|
// Same treatment for the model summary: it decorates rows, so it must not
|
|
356
419
|
// hold the list, and a slow earlier response must not overwrite a newer one.
|
|
357
420
|
void refreshModelSummary();
|
|
421
|
+
void refreshDeviceSummary();
|
|
422
|
+
void refreshBackupSummary();
|
|
358
423
|
} catch (e: any) {
|
|
359
424
|
setError(e.message || 'Loading error');
|
|
360
425
|
} finally {
|
|
@@ -467,7 +532,7 @@ export default function AgentsPageClient() {
|
|
|
467
532
|
}, [agents, filter]);
|
|
468
533
|
|
|
469
534
|
// Derive the selected agent from the live list rather than holding a snapshot.
|
|
470
|
-
// A snapshot goes stale on every 15s poll (status, ports,
|
|
535
|
+
// A snapshot goes stale on every 15s poll (status, ports, version) and
|
|
471
536
|
// survives deletion; deriving means a rename shows up on the next load() and a
|
|
472
537
|
// deleted agent can't leave a detail panel pointing at something that's gone.
|
|
473
538
|
const selected = useMemo(
|
|
@@ -576,7 +641,7 @@ export default function AgentsPageClient() {
|
|
|
576
641
|
{providersConfigured === false && <NoProviderBanner />}
|
|
577
642
|
{/* Scrollable content */}
|
|
578
643
|
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
|
579
|
-
<DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} />
|
|
644
|
+
<DetailPanel key={selected.agentId} agent={selected} busy={!!bkSummary[selected.agentId]} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} onAccessChanged={refreshDeviceSummary} onActivity={notifyActivity} />
|
|
580
645
|
</div>
|
|
581
646
|
</div>
|
|
582
647
|
);
|
|
@@ -611,6 +676,9 @@ export default function AgentsPageClient() {
|
|
|
611
676
|
loading={lifecycleLoading === agent.agentId}
|
|
612
677
|
channelSummary={chSummary}
|
|
613
678
|
modelSummary={mdSummary}
|
|
679
|
+
deviceSummary={dvSummary}
|
|
680
|
+
backupSummary={bkSummary}
|
|
681
|
+
busy={!!bkSummary[agent.agentId]}
|
|
614
682
|
onClick={() => selectAgent(agent)}
|
|
615
683
|
onLifecycle={() => handleLifecycle(agent)}
|
|
616
684
|
/>
|
|
@@ -657,6 +725,9 @@ export default function AgentsPageClient() {
|
|
|
657
725
|
loading={lifecycleLoading === agent.agentId}
|
|
658
726
|
channelSummary={chSummary}
|
|
659
727
|
modelSummary={mdSummary}
|
|
728
|
+
deviceSummary={dvSummary}
|
|
729
|
+
backupSummary={bkSummary}
|
|
730
|
+
busy={!!bkSummary[agent.agentId]}
|
|
660
731
|
selected={isSelected}
|
|
661
732
|
onClick={() => setSelectedId(agent.agentId)}
|
|
662
733
|
onLifecycle={() => handleLifecycle(agent)}
|
|
@@ -671,7 +742,7 @@ export default function AgentsPageClient() {
|
|
|
671
742
|
// key: without it React reuses one DetailPanel instance across
|
|
672
743
|
// selections, so in-flight state (deleting, recreating, restarting,
|
|
673
744
|
// toasts, open confirms) leaks onto the next agent you click.
|
|
674
|
-
<DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} />
|
|
745
|
+
<DetailPanel key={selected.agentId} agent={selected} busy={!!bkSummary[selected.agentId]} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} onAccessChanged={refreshDeviceSummary} onActivity={notifyActivity} />
|
|
675
746
|
)}
|
|
676
747
|
</div>
|
|
677
748
|
</div>
|
|
@@ -680,37 +751,79 @@ export default function AgentsPageClient() {
|
|
|
680
751
|
}
|
|
681
752
|
|
|
682
753
|
// ─── Edit Agent modal ───
|
|
683
|
-
|
|
754
|
+
|
|
755
|
+
/** The host range an agent publishes for its gateway (container ports 3000+), e.g. "3710-3719". */
|
|
756
|
+
function currentPortRange(ports: string): string {
|
|
757
|
+
const hosts = (ports || '')
|
|
758
|
+
.split(',')
|
|
759
|
+
.map((p) => p.trim())
|
|
760
|
+
.filter(Boolean)
|
|
761
|
+
.map((p) => {
|
|
762
|
+
const [host, container] = p.split(':');
|
|
763
|
+
const c = parseInt((container || '').replace(/\/.*$/, ''), 10);
|
|
764
|
+
return Number.isInteger(c) && c >= 3000 ? parseInt(host, 10) : NaN;
|
|
765
|
+
})
|
|
766
|
+
.filter((n): n is number => Number.isInteger(n));
|
|
767
|
+
if (hosts.length === 0) return '';
|
|
768
|
+
const min = Math.min(...hosts);
|
|
769
|
+
const max = Math.max(...hosts);
|
|
770
|
+
return min === max ? `${min}` : `${min}-${max}`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
|
|
684
774
|
agent: Agent;
|
|
685
775
|
onClose: () => void;
|
|
686
776
|
/** Rename succeeded — the parent refetches so the new name propagates. */
|
|
687
777
|
onSaved: () => void;
|
|
778
|
+
/** The edit job started — show the EDITING chip at once. */
|
|
779
|
+
onStarted?: () => void;
|
|
688
780
|
}) {
|
|
689
|
-
const
|
|
781
|
+
const initialName = agent.displayName || agent.name;
|
|
782
|
+
const initialPorts = currentPortRange(agent.ports);
|
|
783
|
+
const [displayName, setDisplayName] = useState(initialName);
|
|
784
|
+
const [portRange, setPortRange] = useState(initialPorts);
|
|
690
785
|
const [saving, setSaving] = useState(false);
|
|
691
786
|
const [error, setError] = useState('');
|
|
692
787
|
|
|
788
|
+
// Client-side shape checks (the same messages as the API). Whether a port is already
|
|
789
|
+
// published by another agent is checked by the server, which knows every container
|
|
790
|
+
// and excludes this agent's own block.
|
|
791
|
+
const nameError = displayName.trim() ? '' : 'Display name must not be empty';
|
|
792
|
+
const portError = useMemo(() => {
|
|
793
|
+
if (!portRange.trim()) return '';
|
|
794
|
+
const r = validatePortInput(portRange);
|
|
795
|
+
return r.valid ? '' : r.error;
|
|
796
|
+
}, [portRange]);
|
|
797
|
+
|
|
798
|
+
const nameChanged = displayName.trim() !== initialName;
|
|
799
|
+
const portsChanged = portRange.trim() !== initialPorts;
|
|
800
|
+
const nothingChanged = !nameChanged && !portsChanged;
|
|
801
|
+
|
|
693
802
|
const handleSave = async () => {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
setError('Display name must not be empty');
|
|
803
|
+
if (nothingChanged) {
|
|
804
|
+
setError('Nothing to change');
|
|
697
805
|
return;
|
|
698
806
|
}
|
|
699
|
-
if (
|
|
700
|
-
|
|
807
|
+
if (nameError || portError) {
|
|
808
|
+
setError('');
|
|
701
809
|
return;
|
|
702
810
|
}
|
|
703
811
|
setSaving(true);
|
|
704
812
|
setError('');
|
|
705
813
|
try {
|
|
814
|
+
const body: { displayName?: string; portRange?: string } = {};
|
|
815
|
+
if (nameChanged) body.displayName = displayName.trim();
|
|
816
|
+
if (portsChanged) body.portRange = portRange.trim();
|
|
706
817
|
const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}`, {
|
|
707
818
|
method: 'PATCH',
|
|
708
819
|
headers: { 'Content-Type': 'application/json' },
|
|
709
|
-
body: JSON.stringify(
|
|
820
|
+
body: JSON.stringify(body),
|
|
710
821
|
});
|
|
711
822
|
const data = await res.json();
|
|
712
823
|
if (!res.ok) throw new Error(data.error || 'Failed to update');
|
|
713
|
-
//
|
|
824
|
+
// The job is async (202): show the chip now, and refetch the list so the new
|
|
825
|
+
// display name propagates once the rebuild finishes.
|
|
826
|
+
onStarted?.();
|
|
714
827
|
onSaved();
|
|
715
828
|
onClose();
|
|
716
829
|
} catch (e: unknown) {
|
|
@@ -746,13 +859,27 @@ function EditAgentModal({ agent, onClose, onSaved }: {
|
|
|
746
859
|
value={displayName}
|
|
747
860
|
onChange={(e) => { setDisplayName(e.target.value); setError(''); }}
|
|
748
861
|
placeholder="My Agent 🚀"
|
|
749
|
-
error={
|
|
862
|
+
error={nameError}
|
|
863
|
+
disabled={saving}
|
|
864
|
+
/>
|
|
865
|
+
</div>
|
|
866
|
+
<div>
|
|
867
|
+
<Input
|
|
868
|
+
label="Host Port"
|
|
869
|
+
value={portRange}
|
|
870
|
+
onChange={(e) => { setPortRange(e.target.value.replace(/[^0-9-]/g, '').slice(0, 11)); setError(''); }}
|
|
871
|
+
placeholder="3700-3709"
|
|
872
|
+
error={portError}
|
|
750
873
|
disabled={saving}
|
|
751
874
|
/>
|
|
752
875
|
<div style={{ fontSize: 10, color: '#666', marginTop: 4 }}>
|
|
753
|
-
|
|
876
|
+
Port range (e.g. 3700-3709) or single port (e.g. 3700). Maps onto the container's port 3000; the Control UI URL changes with it.
|
|
754
877
|
</div>
|
|
755
878
|
</div>
|
|
879
|
+
<div style={{ fontSize: 10, color: '#666' }}>
|
|
880
|
+
Container: {agent.name} · This recreates the container without a backup. Sessions and workspace are preserved.
|
|
881
|
+
</div>
|
|
882
|
+
{error && <div style={{ fontSize: 11, color: '#ef4444' }}>{error}</div>}
|
|
756
883
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
757
884
|
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>Cancel</Button>
|
|
758
885
|
<Button variant="primary" size="sm" onClick={handleSave} disabled={saving}>
|
|
@@ -835,7 +962,77 @@ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void })
|
|
|
835
962
|
}
|
|
836
963
|
|
|
837
964
|
// ─── Detail panel (shared between mobile & desktop) ───
|
|
838
|
-
|
|
965
|
+
/* ─── Edit banner - shown while a rename/port change rebuilds the container ─── */
|
|
966
|
+
|
|
967
|
+
interface EditJob {
|
|
968
|
+
status: 'rebuilding' | 'done' | 'failed' | 'interrupted';
|
|
969
|
+
displayName: string | null;
|
|
970
|
+
portRange: string | null;
|
|
971
|
+
error: string | null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Visible while `PATCH /api/agents/[id]` is rebuilding the container, and resumed on
|
|
976
|
+
* mount so a reload or a navigation shows the running edit instead of allowing a second.
|
|
977
|
+
* The server answers 409 to any new edit anyway.
|
|
978
|
+
*/
|
|
979
|
+
function EditBanner({ agentId, onChanged, ping }: { agentId: string; onChanged: () => void; ping: number }) {
|
|
980
|
+
const [job, setJob] = useState<EditJob | null>(null);
|
|
981
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
982
|
+
// Held in a ref: calling it must not re-run the effect (the parent may hand a new one
|
|
983
|
+
// every render), which would restart the poll on every render.
|
|
984
|
+
const onChangedRef = useRef(onChanged);
|
|
985
|
+
onChangedRef.current = onChanged;
|
|
986
|
+
|
|
987
|
+
useEffect(() => {
|
|
988
|
+
let alive = true;
|
|
989
|
+
let shown = false;
|
|
990
|
+
const controller = new AbortController();
|
|
991
|
+
abortRef.current = controller;
|
|
992
|
+
const tick = async () => {
|
|
993
|
+
try {
|
|
994
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}`, { signal: controller.signal });
|
|
995
|
+
if (!res.ok) return;
|
|
996
|
+
const data = await res.json();
|
|
997
|
+
const cur: EditJob | null = data.edit ?? null;
|
|
998
|
+
if (cur?.status === 'rebuilding') { shown = true; setJob(cur); return; }
|
|
999
|
+
if (shown) {
|
|
1000
|
+
shown = false;
|
|
1001
|
+
setJob(null);
|
|
1002
|
+
if (cur && (cur.status === 'done' || cur.status === 'failed')) onChangedRef.current();
|
|
1003
|
+
}
|
|
1004
|
+
} catch { /* aborted or transient — the next tick retries */ }
|
|
1005
|
+
};
|
|
1006
|
+
// `ping` changes when an edit is started here, so the banner appears at once; the
|
|
1007
|
+
// 5 s poll then keeps it fresh and clears it when the job finishes.
|
|
1008
|
+
void tick();
|
|
1009
|
+
const timer = setInterval(() => { if (alive) void tick(); }, 5000);
|
|
1010
|
+
return () => { alive = false; clearInterval(timer); controller.abort(); };
|
|
1011
|
+
}, [agentId, ping]);
|
|
1012
|
+
|
|
1013
|
+
if (!job) return null;
|
|
1014
|
+
return (
|
|
1015
|
+
<div style={{
|
|
1016
|
+
padding: '8px 12px',
|
|
1017
|
+
fontSize: 11,
|
|
1018
|
+
fontFamily: 'var(--font-mono)',
|
|
1019
|
+
color: 'var(--violet)',
|
|
1020
|
+
background: 'rgba(146,91,252,0.08)',
|
|
1021
|
+
border: '1px solid rgba(146,91,252,0.25)',
|
|
1022
|
+
display: 'flex',
|
|
1023
|
+
alignItems: 'center',
|
|
1024
|
+
gap: 8,
|
|
1025
|
+
}}>
|
|
1026
|
+
<span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: 'var(--violet)', animation: 'pulse 1s infinite', flexShrink: 0 }} />
|
|
1027
|
+
<span style={{ flex: 1, minWidth: 0, wordBreak: 'break-all' }}>
|
|
1028
|
+
Rebuilding the container{job.displayName ? ` as ${job.displayName}` : ''}
|
|
1029
|
+
<span style={{ whiteSpace: 'nowrap' }}> (the agent restarts when done)</span>
|
|
1030
|
+
</span>
|
|
1031
|
+
</div>
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function DetailPanel({ agent, busy, onDeleted, onChanged, onModelSaved, onAccessChanged, onActivity }: {
|
|
839
1036
|
agent: Agent;
|
|
840
1037
|
/** The agent is gone — the parent drops it from the list. */
|
|
841
1038
|
onDeleted: (agentId: string) => void;
|
|
@@ -847,17 +1044,24 @@ function DetailPanel({ agent, onDeleted, onChanged, onModelSaved }: {
|
|
|
847
1044
|
* summary for a field the parent already knows how to reload on its own.
|
|
848
1045
|
*/
|
|
849
1046
|
onModelSaved: () => void;
|
|
1047
|
+
/** A browser was approved, rejected or revoked — refresh the list badges. */
|
|
1048
|
+
onAccessChanged: () => void;
|
|
1049
|
+
/** A long job started here — show its chip at once (page-level notifyActivity). */
|
|
1050
|
+
onActivity: (agentId: string, kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit') => void;
|
|
1051
|
+
/** This agent has a long operation running: its actions are disabled. */
|
|
1052
|
+
busy: boolean;
|
|
850
1053
|
}) {
|
|
851
1054
|
const [deleting, setDeleting] = useState(false);
|
|
852
1055
|
const [deleteError, setDeleteError] = useState('');
|
|
853
1056
|
const [showConfig, setShowConfig] = useState(false);
|
|
854
1057
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
855
1058
|
const [showEdit, setShowEdit] = useState(false);
|
|
1059
|
+
/** Bumped when an edit starts here, so the banner refetches at once. */
|
|
1060
|
+
const [editPing, setEditPing] = useState(0);
|
|
856
1061
|
|
|
857
1062
|
// Safe to resolve during render: this panel only mounts once the agent list has
|
|
858
1063
|
// been fetched client-side, so it never runs in a server render. The helper
|
|
859
1064
|
// guards for a missing `window` regardless.
|
|
860
|
-
const controlUrl = agentControlUrl(agent);
|
|
861
1065
|
|
|
862
1066
|
const handleDelete = async () => {
|
|
863
1067
|
setDeleting(true);
|
|
@@ -889,7 +1093,7 @@ function DetailPanel({ agent, onDeleted, onChanged, onModelSaved }: {
|
|
|
889
1093
|
<div>
|
|
890
1094
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
891
1095
|
<span style={{ fontSize: 16, fontWeight: 500 }}>{agent.displayName || agent.name}</span>
|
|
892
|
-
<Button variant="primary" size="sm" onClick={() => setShowEdit(true)} title=
|
|
1096
|
+
<Button variant="primary" size="sm" disabled={busy} onClick={() => setShowEdit(true)} title={busy ? 'An operation is running on this agent' : 'Edit agent settings'}>
|
|
893
1097
|
✎
|
|
894
1098
|
</Button>
|
|
895
1099
|
</div>
|
|
@@ -904,6 +1108,9 @@ function DetailPanel({ agent, onDeleted, onChanged, onModelSaved }: {
|
|
|
904
1108
|
</div>
|
|
905
1109
|
</div>
|
|
906
1110
|
|
|
1111
|
+
{/* Rename / ports rebuild, resumed on mount */}
|
|
1112
|
+
<EditBanner agentId={agent.agentId} onChanged={onChanged} ping={editPing} />
|
|
1113
|
+
|
|
907
1114
|
{/* Properties */}
|
|
908
1115
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
909
1116
|
<SectionHeader icon={<InfoIcon size={12} />} label="PROPERTIES" />
|
|
@@ -913,6 +1120,7 @@ function DetailPanel({ agent, onDeleted, onChanged, onModelSaved }: {
|
|
|
913
1120
|
['Container', agent.name],
|
|
914
1121
|
['Image', agent.image],
|
|
915
1122
|
['Tag', agent.imageTag],
|
|
1123
|
+
['OpenClaw', agent.openclawVersion || 'unknown'],
|
|
916
1124
|
['Template', agent.template || '-'],
|
|
917
1125
|
['State', agent.state],
|
|
918
1126
|
['Ports', agent.ports || '-'],
|
|
@@ -927,12 +1135,25 @@ function DetailPanel({ agent, onDeleted, onChanged, onModelSaved }: {
|
|
|
927
1135
|
</div>
|
|
928
1136
|
|
|
929
1137
|
{/* Channels - Telegram */}
|
|
930
|
-
<ChannelSection agentId={agent.agentId}
|
|
1138
|
+
<ChannelSection agentId={agent.agentId} displayName={agent.displayName || agent.name} />
|
|
1139
|
+
|
|
1140
|
+
{/* Browser access - open the Control UI, approve and revoke browsers */}
|
|
1141
|
+
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1142
|
+
<SectionHeader icon={<KeyIcon size={12} />} label="BROWSER ACCESS" />
|
|
1143
|
+
<div style={{ padding: '12px 14px' }}>
|
|
1144
|
+
{agent.state === 'running' && agent.controlPort && (
|
|
1145
|
+
<div style={{ marginBottom: 8 }}>
|
|
1146
|
+
<OpenControlUiButton agentId={agent.agentId} label="Open Control UI ↗" />
|
|
1147
|
+
</div>
|
|
1148
|
+
)}
|
|
1149
|
+
<BrowserAccessSection agentId={agent.agentId} running={agent.state === 'running'} onChanged={onAccessChanged} />
|
|
1150
|
+
</div>
|
|
1151
|
+
</div>
|
|
931
1152
|
|
|
932
1153
|
{/* Model - primary + fallbacks (moved here from the Gateway page) */}
|
|
933
1154
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
934
1155
|
<SectionHeader icon={<BracesIcon size={12} />} label="MODEL" />
|
|
935
|
-
<ModelSection agentId={agent.agentId} onSaved={onModelSaved} />
|
|
1156
|
+
<ModelSection agentId={agent.agentId} onSaved={onModelSaved} agentBusy={busy} />
|
|
936
1157
|
</div>
|
|
937
1158
|
|
|
938
1159
|
{/* Config - View openclaw.json */}
|
|
@@ -952,30 +1173,16 @@ function DetailPanel({ agent, onDeleted, onChanged, onModelSaved }: {
|
|
|
952
1173
|
<IntegrationSection agentName={agent.name} />
|
|
953
1174
|
|
|
954
1175
|
{/* Restart */}
|
|
955
|
-
<RestartSection agentName={agent.name} />
|
|
1176
|
+
<RestartSection agentName={agent.name} agentBusy={busy} />
|
|
956
1177
|
|
|
957
1178
|
{/* Backup - persistent volume backup & restore */}
|
|
958
|
-
<BackupSection agentName={agent.name} />
|
|
959
|
-
|
|
960
|
-
{/* Access - agent Control UI URL (token inline in link) */}
|
|
961
|
-
{controlUrl && (
|
|
962
|
-
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
963
|
-
<SectionHeader icon={<KeyIcon size={12} />} label="ACCESS" />
|
|
964
|
-
<div style={{ padding: '7px 14px', fontSize: 12 }}>
|
|
965
|
-
<div style={{ color: '#888', marginBottom: 2 }}>Control UI</div>
|
|
966
|
-
<a href={controlUrl} target="_blank" rel="noopener noreferrer"
|
|
967
|
-
style={{ color: '#60a5fa', textDecoration: 'underline', fontSize: 12, wordBreak: 'break-all' }}
|
|
968
|
-
>{controlUrl}</a>
|
|
969
|
-
|
|
970
|
-
</div>
|
|
971
|
-
</div>
|
|
972
|
-
)}
|
|
1179
|
+
<BackupSection agentName={agent.name} agentBusy={busy} onStarted={(kind) => onActivity(agent.agentId, kind)} />
|
|
973
1180
|
|
|
974
1181
|
{/* Config modal */}
|
|
975
1182
|
{showConfig && <ConfigModal agent={agent} onClose={() => setShowConfig(false)} />}
|
|
976
1183
|
|
|
977
1184
|
{/* Edit agent modal */}
|
|
978
|
-
{showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} onSaved={onChanged} />}
|
|
1185
|
+
{showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} onSaved={onChanged} onStarted={() => { setEditPing(Date.now()); onActivity(agent.agentId, 'edit'); }} />}
|
|
979
1186
|
|
|
980
1187
|
{/* Delete confirmation */}
|
|
981
1188
|
<ConfirmModal
|
|
@@ -990,15 +1197,14 @@ function DetailPanel({ agent, onDeleted, onChanged, onModelSaved }: {
|
|
|
990
1197
|
/>
|
|
991
1198
|
|
|
992
1199
|
{/* Recreate */}
|
|
993
|
-
{
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
</div>
|
|
1200
|
+
{/* OpenClaw version and the Update action */}
|
|
1201
|
+
<div style={{ marginBottom: 12, border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1202
|
+
<SectionHeader icon={<RotateIcon size={12} />} label="OPENCLAW VERSION" />
|
|
1203
|
+
<div style={{ padding: '12px 14px' }}>
|
|
1204
|
+
<UpdateSection agentId={agent.agentId} running={agent.state === 'running'} onChanged={onChanged} onStarted={() => onActivity(agent.agentId, 'update')} />
|
|
999
1205
|
</div>
|
|
1000
|
-
|
|
1001
|
-
<RecreateSection agentName={agent.name} onRecreated={onChanged} />
|
|
1206
|
+
</div>
|
|
1207
|
+
<RecreateSection agentName={agent.name} agentBusy={busy} onRecreated={onChanged} onStarted={() => onActivity(agent.agentId, 'recreate')} />
|
|
1002
1208
|
|
|
1003
1209
|
{/* Delete */}
|
|
1004
1210
|
<div style={{ border: '1px solid rgba(239,68,68,0.3)', borderRadius: 0, background: 'rgba(239,68,68,0.04)', overflow: 'hidden' }}>
|
|
@@ -1094,98 +1300,221 @@ function IntegrationSection({ agentName }: { agentName: string }) {
|
|
|
1094
1300
|
);
|
|
1095
1301
|
}
|
|
1096
1302
|
|
|
1097
|
-
/* ─── Backup section - persistent volume backup & restore ─── */
|
|
1303
|
+
/* ─── Backup section - persistent volume backup & restore (cold backup) ─── */
|
|
1304
|
+
|
|
1305
|
+
interface ColdBackupJob {
|
|
1306
|
+
status: 'running' | 'succeeded' | 'failed';
|
|
1307
|
+
file: string;
|
|
1308
|
+
kind?: string;
|
|
1309
|
+
percent: number | null;
|
|
1310
|
+
error: string | null;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
interface RestoreJob {
|
|
1314
|
+
status: 'restoring' | 'done' | 'failed' | 'interrupted';
|
|
1315
|
+
file: string | null;
|
|
1316
|
+
error: string | null;
|
|
1317
|
+
}
|
|
1098
1318
|
|
|
1099
|
-
function BackupSection({ agentName }: { agentName: string }) {
|
|
1319
|
+
function BackupSection({ agentName, agentBusy, onStarted }: { agentName: string; agentBusy?: boolean; onStarted?: (kind: 'backup' | 'restore') => void }) {
|
|
1100
1320
|
const [backups, setBackups] = useState<{ name: string; size: string; date: string }[]>([]);
|
|
1101
1321
|
const [loading, setLoading] = useState(false);
|
|
1102
|
-
const [
|
|
1322
|
+
const [backup, setBackup] = useState<ColdBackupJob | null>(null);
|
|
1323
|
+
const [restore, setRestore] = useState<RestoreJob | null>(null);
|
|
1324
|
+
/** The one action in flight, and the file it is working on (keyed busy state). */
|
|
1325
|
+
const [busy, setBusy] = useState<{ kind: 'starting' | 'cancelling' | 'restoring' | 'deleting'; file?: string } | null>(null);
|
|
1103
1326
|
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
|
1104
1327
|
const [restoreConfirm, setRestoreConfirm] = useState<string | null>(null);
|
|
1328
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
1329
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
1105
1330
|
|
|
1106
1331
|
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
|
1107
1332
|
setToast({ msg, type });
|
|
1108
1333
|
setTimeout(() => setToast(null), 4000);
|
|
1109
1334
|
};
|
|
1110
1335
|
|
|
1336
|
+
const stopPolling = useCallback(() => {
|
|
1337
|
+
if (pollRef.current) {
|
|
1338
|
+
clearInterval(pollRef.current);
|
|
1339
|
+
pollRef.current = null;
|
|
1340
|
+
}
|
|
1341
|
+
}, []);
|
|
1342
|
+
|
|
1111
1343
|
const loadBackups = useCallback(async () => {
|
|
1344
|
+
abortRef.current?.abort();
|
|
1345
|
+
const controller = new AbortController();
|
|
1346
|
+
abortRef.current = controller;
|
|
1112
1347
|
setLoading(true);
|
|
1113
1348
|
try {
|
|
1114
|
-
const res = await fetch(`/api/agents/${agentName}/backup
|
|
1349
|
+
const res = await fetch(`/api/agents/${agentName}/backup`, { signal: controller.signal });
|
|
1115
1350
|
if (res.ok) {
|
|
1116
1351
|
const data: { backups: { name: string; size: string; date: string }[] } = await res.json();
|
|
1117
1352
|
setBackups(data.backups ?? []);
|
|
1118
1353
|
}
|
|
1119
|
-
} catch { /*
|
|
1354
|
+
} catch { /* aborted or transient — the list stays as it is */ }
|
|
1120
1355
|
setLoading(false);
|
|
1121
1356
|
}, [agentName]);
|
|
1122
1357
|
|
|
1123
|
-
|
|
1358
|
+
/* Poll a running job every 2s until it finishes; the job lives in Docker
|
|
1359
|
+
(a detached helper container), so this resumes cleanly after a reload. */
|
|
1360
|
+
const startPolling = useCallback(() => {
|
|
1361
|
+
stopPolling();
|
|
1362
|
+
const controller = new AbortController();
|
|
1363
|
+
abortRef.current = controller;
|
|
1364
|
+
pollRef.current = setInterval(async () => {
|
|
1365
|
+
try {
|
|
1366
|
+
const res = await fetch(`/api/agents/${agentName}/cold-backup`, { signal: controller.signal });
|
|
1367
|
+
if (!res.ok) return;
|
|
1368
|
+
const data = await res.json();
|
|
1369
|
+
const job: ColdBackupJob | null = data.backup ?? null;
|
|
1370
|
+
if (job?.status === 'running') {
|
|
1371
|
+
setBackup(job);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
stopPolling();
|
|
1375
|
+
setBackup(null);
|
|
1376
|
+
if (job?.status === 'succeeded') showToast('Backup completed: ' + job.file);
|
|
1377
|
+
else if (job?.status === 'failed') showToast('Backup failed: ' + (job.error ?? 'unknown error'), 'error');
|
|
1378
|
+
await loadBackups();
|
|
1379
|
+
} catch { /* aborted or transient network error — the next tick retries */ }
|
|
1380
|
+
}, 2000);
|
|
1381
|
+
}, [agentName, loadBackups, stopPolling]);
|
|
1382
|
+
|
|
1383
|
+
/* Poll a running restore every 2s; the job is recorded server-side, so this resumes
|
|
1384
|
+
after a reload and a second restore cannot start meanwhile (the server answers 409). */
|
|
1385
|
+
const startRestorePolling = useCallback(() => {
|
|
1386
|
+
stopPolling();
|
|
1387
|
+
const controller = new AbortController();
|
|
1388
|
+
abortRef.current = controller;
|
|
1389
|
+
pollRef.current = setInterval(async () => {
|
|
1390
|
+
try {
|
|
1391
|
+
const res = await fetch(`/api/agents/${agentName}/restore`, { signal: controller.signal });
|
|
1392
|
+
if (!res.ok) return;
|
|
1393
|
+
const data = await res.json();
|
|
1394
|
+
const job: RestoreJob | null = data.restore ?? null;
|
|
1395
|
+
if (job?.status === 'restoring') {
|
|
1396
|
+
setRestore(job);
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
stopPolling();
|
|
1400
|
+
setRestore(null);
|
|
1401
|
+
if (job?.status === 'done') showToast('Restored from ' + (job.file ?? 'the backup'));
|
|
1402
|
+
else if (job?.status === 'failed') showToast('Restore failed: ' + (job.error ?? 'unknown error'), 'error');
|
|
1403
|
+
await loadBackups();
|
|
1404
|
+
} catch { /* aborted or transient network error — the next tick retries */ }
|
|
1405
|
+
}, 2000);
|
|
1406
|
+
}, [agentName, loadBackups, stopPolling]);
|
|
1407
|
+
|
|
1408
|
+
/* On mount: load the archive list and pick up a backup or restore already running. */
|
|
1409
|
+
useEffect(() => {
|
|
1410
|
+
let alive = true;
|
|
1411
|
+
void loadBackups();
|
|
1412
|
+
void (async () => {
|
|
1413
|
+
try {
|
|
1414
|
+
const res = await fetch(`/api/agents/${agentName}/cold-backup`);
|
|
1415
|
+
if (res.ok && alive) {
|
|
1416
|
+
const data = await res.json();
|
|
1417
|
+
if (data.backup?.status === 'running') {
|
|
1418
|
+
setBackup(data.backup);
|
|
1419
|
+
startPolling();
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
const rRes = await fetch(`/api/agents/${agentName}/restore`);
|
|
1424
|
+
if (rRes.ok && alive) {
|
|
1425
|
+
const rData = await rRes.json();
|
|
1426
|
+
if (rData.restore?.status === 'restoring') {
|
|
1427
|
+
setRestore(rData.restore);
|
|
1428
|
+
startRestorePolling();
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
} catch { /* ignore */ }
|
|
1432
|
+
})();
|
|
1433
|
+
return () => { alive = false; stopPolling(); };
|
|
1434
|
+
}, [agentName, startPolling, startRestorePolling, stopPolling, loadBackups]);
|
|
1124
1435
|
|
|
1125
1436
|
const handleBackup = async () => {
|
|
1126
|
-
|
|
1127
|
-
setBackups([]);
|
|
1437
|
+
setBusy({ kind: 'starting' });
|
|
1128
1438
|
try {
|
|
1129
|
-
const res = await fetch(`/api/agents/${agentName}/backup`, { method: 'POST' });
|
|
1439
|
+
const res = await fetch(`/api/agents/${agentName}/cold-backup`, { method: 'POST' });
|
|
1130
1440
|
const data = await res.json();
|
|
1131
|
-
if (data.
|
|
1132
|
-
|
|
1133
|
-
|
|
1441
|
+
if (res.status === 202 && data.started) {
|
|
1442
|
+
setBackup({ status: 'running', file: data.file, percent: null, error: null });
|
|
1443
|
+
onStarted?.('backup');
|
|
1444
|
+
startPolling();
|
|
1134
1445
|
} else {
|
|
1135
|
-
showToast(data.error || 'Backup failed', 'error');
|
|
1446
|
+
showToast(data.error || 'Backup failed to start', 'error');
|
|
1136
1447
|
}
|
|
1137
1448
|
} catch (e: unknown) {
|
|
1138
1449
|
showToast((e as Error).message, 'error');
|
|
1139
1450
|
}
|
|
1140
|
-
|
|
1451
|
+
setBusy(null);
|
|
1141
1452
|
};
|
|
1142
1453
|
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1454
|
+
const handleCancelBackup = async () => {
|
|
1455
|
+
setBusy({ kind: 'cancelling' });
|
|
1456
|
+
try {
|
|
1457
|
+
const res = await fetch(`/api/agents/${agentName}/cold-backup`, { method: 'DELETE' });
|
|
1458
|
+
const data = await res.json().catch(() => ({}));
|
|
1459
|
+
if (!res.ok) {
|
|
1460
|
+
showToast(data.error || 'Cancel failed', 'error');
|
|
1461
|
+
} else {
|
|
1462
|
+
stopPolling();
|
|
1463
|
+
setBackup(null);
|
|
1464
|
+
showToast('Backup cancelled');
|
|
1465
|
+
await loadBackups();
|
|
1466
|
+
}
|
|
1467
|
+
} catch (e: unknown) {
|
|
1468
|
+
showToast((e as Error).message, 'error');
|
|
1469
|
+
}
|
|
1470
|
+
setBusy(null);
|
|
1145
1471
|
};
|
|
1146
1472
|
|
|
1147
1473
|
const executeRestore = async () => {
|
|
1148
1474
|
const file = restoreConfirm;
|
|
1149
1475
|
if (!file) return;
|
|
1150
1476
|
setRestoreConfirm(null);
|
|
1151
|
-
|
|
1152
|
-
setBackups([]);
|
|
1477
|
+
setBusy({ kind: 'restoring', file });
|
|
1153
1478
|
try {
|
|
1154
1479
|
const res = await fetch(`/api/agents/${agentName}/restore?file=${encodeURIComponent(file)}`, { method: 'POST' });
|
|
1155
1480
|
const data = await res.json();
|
|
1156
|
-
if (data.
|
|
1157
|
-
|
|
1158
|
-
|
|
1481
|
+
if (res.status === 202 && data.started) {
|
|
1482
|
+
setRestore({ status: 'restoring', file, error: null });
|
|
1483
|
+
onStarted?.('restore');
|
|
1484
|
+
startRestorePolling();
|
|
1159
1485
|
} else {
|
|
1160
|
-
showToast(data.error || 'Restore failed', 'error');
|
|
1486
|
+
showToast(data.error || 'Restore failed to start', 'error');
|
|
1161
1487
|
}
|
|
1162
1488
|
} catch (e: unknown) {
|
|
1163
1489
|
showToast((e as Error).message, 'error');
|
|
1164
1490
|
}
|
|
1165
|
-
|
|
1491
|
+
setBusy(null);
|
|
1166
1492
|
};
|
|
1167
1493
|
|
|
1168
1494
|
const handleDeleteBackup = async (file: string) => {
|
|
1169
|
-
|
|
1495
|
+
setBusy({ kind: 'deleting', file });
|
|
1170
1496
|
try {
|
|
1171
1497
|
const res = await fetch(`/api/agents/${agentName}/backup?file=${encodeURIComponent(file)}`, { method: 'DELETE' });
|
|
1172
1498
|
const data = await res.json().catch(() => ({}));
|
|
1173
1499
|
if (!res.ok || !data.success) {
|
|
1174
1500
|
showToast(data.error || 'Delete failed', 'error');
|
|
1175
1501
|
}
|
|
1176
|
-
setBackups([]);
|
|
1177
1502
|
await loadBackups();
|
|
1178
1503
|
} catch (e: unknown) {
|
|
1179
1504
|
showToast((e as Error).message, 'error');
|
|
1180
1505
|
}
|
|
1181
|
-
|
|
1506
|
+
setBusy(null);
|
|
1182
1507
|
};
|
|
1183
1508
|
|
|
1509
|
+
const running = backup?.status === 'running';
|
|
1510
|
+
const restoring = restore?.status === 'restoring';
|
|
1511
|
+
const actionsLocked = running || restoring || busy !== null || !!agentBusy;
|
|
1512
|
+
|
|
1184
1513
|
return (
|
|
1185
1514
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1186
1515
|
<SectionHeader icon={<ArchiveIcon size={12} />} label="BACKUP" />
|
|
1187
1516
|
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
1188
|
-
{
|
|
1517
|
+
{running && (
|
|
1189
1518
|
<div style={{
|
|
1190
1519
|
padding: '8px 12px',
|
|
1191
1520
|
fontSize: 11,
|
|
@@ -1203,50 +1532,99 @@ function BackupSection({ agentName }: { agentName: string }) {
|
|
|
1203
1532
|
borderRadius: '50%',
|
|
1204
1533
|
background: 'var(--violet)',
|
|
1205
1534
|
animation: 'pulse 1s infinite',
|
|
1535
|
+
flexShrink: 0,
|
|
1206
1536
|
}} />
|
|
1207
|
-
{
|
|
1537
|
+
<span style={{ flex: 1, minWidth: 0, wordBreak: 'break-all' }}>
|
|
1538
|
+
Backing up {backup?.file}
|
|
1539
|
+
{/* Inline and nowrap: the file name may break, but the percentage and the
|
|
1540
|
+
note move to the next line as one piece instead of splitting "5" / "3%". */}
|
|
1541
|
+
<span style={{ whiteSpace: 'nowrap' }}>
|
|
1542
|
+
{' '}{backup?.percent !== null && backup?.percent !== undefined ? `— ${backup.percent}% ` : ''}(the agent restarts when done)
|
|
1543
|
+
</span>
|
|
1544
|
+
</span>
|
|
1545
|
+
<span style={{ flexShrink: 0 }}>
|
|
1546
|
+
<Button
|
|
1547
|
+
variant="secondary"
|
|
1548
|
+
size="sm"
|
|
1549
|
+
loading={busy?.kind === 'cancelling'}
|
|
1550
|
+
disabled={busy !== null}
|
|
1551
|
+
onClick={handleCancelBackup}
|
|
1552
|
+
>
|
|
1553
|
+
{busy?.kind === 'cancelling' ? 'Cancelling…' : 'Cancel'}
|
|
1554
|
+
</Button>
|
|
1555
|
+
</span>
|
|
1208
1556
|
</div>
|
|
1209
1557
|
)}
|
|
1210
|
-
{
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
)
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1558
|
+
{restoring && (
|
|
1559
|
+
<div style={{
|
|
1560
|
+
padding: '8px 12px',
|
|
1561
|
+
fontSize: 11,
|
|
1562
|
+
fontFamily: 'var(--font-mono)',
|
|
1563
|
+
color: 'var(--violet)',
|
|
1564
|
+
background: 'rgba(146,91,252,0.08)',
|
|
1565
|
+
border: '1px solid rgba(146,91,252,0.25)',
|
|
1566
|
+
display: 'flex',
|
|
1567
|
+
alignItems: 'center',
|
|
1568
|
+
gap: 8,
|
|
1569
|
+
}}>
|
|
1570
|
+
<span style={{
|
|
1571
|
+
display: 'inline-block',
|
|
1572
|
+
width: 8, height: 8,
|
|
1573
|
+
borderRadius: '50%',
|
|
1574
|
+
background: 'var(--violet)',
|
|
1575
|
+
animation: 'pulse 1s infinite',
|
|
1576
|
+
flexShrink: 0,
|
|
1577
|
+
}} />
|
|
1578
|
+
<span style={{ flex: 1, minWidth: 0, wordBreak: 'break-all' }}>
|
|
1579
|
+
Restoring {restore?.file}
|
|
1580
|
+
<span style={{ whiteSpace: 'nowrap' }}> (the agent restarts when done)</span>
|
|
1581
|
+
</span>
|
|
1582
|
+
</div>
|
|
1583
|
+
)}
|
|
1584
|
+
{loading && backups.length === 0 && <div style={{ fontSize: 11, color: '#888' }}>Loading...</div>}
|
|
1585
|
+
{!loading && backups.length === 0 && (
|
|
1586
|
+
<div style={{ fontSize: 11, color: '#888' }}>No backups yet.</div>
|
|
1587
|
+
)}
|
|
1588
|
+
{backups.length > 0 && (
|
|
1589
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
1590
|
+
{backups.map((b) => (
|
|
1591
|
+
// Wraps instead of crushing the name: the name asks for a 220px basis and
|
|
1592
|
+
// grows; when the row can no longer fit size, date and the buttons beside
|
|
1593
|
+
// it, they move to a second line rather than squeezing the name to one
|
|
1594
|
+
// character per line.
|
|
1595
|
+
<div key={b.name} style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, fontSize: 11, fontFamily: 'var(--font-mono)', padding: '4px 0' }}>
|
|
1596
|
+
<span style={{ flex: '1 1 220px', minWidth: 0, color: 'var(--text)', wordBreak: 'break-all' }}>{b.name}</span>
|
|
1597
|
+
<span style={{ color: '#888', whiteSpace: 'nowrap', flexShrink: 0 }}>{b.size}</span>
|
|
1598
|
+
<span style={{ color: '#666', whiteSpace: 'nowrap', flexShrink: 0 }}>{b.date}</span>
|
|
1599
|
+
<span style={{ display: 'flex', gap: 8, flexShrink: 0, marginLeft: 'auto' }}>
|
|
1600
|
+
<Button
|
|
1601
|
+
variant="secondary"
|
|
1602
|
+
size="sm"
|
|
1603
|
+
disabled={actionsLocked}
|
|
1604
|
+
loading={busy?.kind === 'restoring' && busy.file === b.name}
|
|
1605
|
+
onClick={() => setRestoreConfirm(b.name)}
|
|
1606
|
+
>
|
|
1607
|
+
Restore
|
|
1608
|
+
</Button>
|
|
1609
|
+
<Button
|
|
1610
|
+
variant="danger"
|
|
1611
|
+
size="sm"
|
|
1612
|
+
disabled={actionsLocked}
|
|
1613
|
+
loading={busy?.kind === 'deleting' && busy.file === b.name}
|
|
1614
|
+
onClick={() => handleDeleteBackup(b.name)}
|
|
1615
|
+
>
|
|
1616
|
+
Delete
|
|
1617
|
+
</Button>
|
|
1618
|
+
</span>
|
|
1241
1619
|
</div>
|
|
1242
|
-
)}
|
|
1243
|
-
|
|
1244
|
-
<Button variant="secondary" size="sm" disabled={!!status} onClick={handleBackup}>
|
|
1245
|
-
Backup Now
|
|
1246
|
-
</Button>
|
|
1247
|
-
</div>
|
|
1248
|
-
</>
|
|
1620
|
+
))}
|
|
1621
|
+
</div>
|
|
1249
1622
|
)}
|
|
1623
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
1624
|
+
<Button variant="secondary" size="sm" disabled={actionsLocked} onClick={handleBackup}>
|
|
1625
|
+
{busy?.kind === 'starting' ? 'Starting…' : 'Backup Now'}
|
|
1626
|
+
</Button>
|
|
1627
|
+
</div>
|
|
1250
1628
|
</div>
|
|
1251
1629
|
{toast && (
|
|
1252
1630
|
<div style={{ padding: '8px 14px', borderTop: '1px solid var(--border)', fontSize: 11, color: toast.type === 'success' ? '#22c55e' : '#ef4444' }}>
|
|
@@ -1269,7 +1647,7 @@ function BackupSection({ agentName }: { agentName: string }) {
|
|
|
1269
1647
|
|
|
1270
1648
|
/* ─── Restart section - simple container restart ─── */
|
|
1271
1649
|
|
|
1272
|
-
function RestartSection({ agentName }: { agentName: string }) {
|
|
1650
|
+
function RestartSection({ agentName, agentBusy }: { agentName: string; agentBusy?: boolean }) {
|
|
1273
1651
|
const [restarting, setRestarting] = useState(false);
|
|
1274
1652
|
const [toast, setToast] = useState<string | null>(null);
|
|
1275
1653
|
|
|
@@ -1301,7 +1679,7 @@ function RestartSection({ agentName }: { agentName: string }) {
|
|
|
1301
1679
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
|
|
1302
1680
|
Restart the container without losing any data.
|
|
1303
1681
|
</div>
|
|
1304
|
-
<Button variant="secondary" size="sm" loading={restarting} disabled={restarting} onClick={handleRestart}>
|
|
1682
|
+
<Button variant="secondary" size="sm" loading={restarting} disabled={restarting || !!agentBusy} onClick={handleRestart}>
|
|
1305
1683
|
{restarting ? 'Restarting...' : 'Restart Container'}
|
|
1306
1684
|
</Button>
|
|
1307
1685
|
</div>
|
|
@@ -1316,47 +1694,121 @@ function RestartSection({ agentName }: { agentName: string }) {
|
|
|
1316
1694
|
|
|
1317
1695
|
/* ─── Recreate section - rebuild container from same image while keeping volume ─── */
|
|
1318
1696
|
|
|
1319
|
-
|
|
1697
|
+
interface RecreateJob {
|
|
1698
|
+
status: 'backing_up' | 'recreating' | 'done' | 'failed' | 'interrupted';
|
|
1699
|
+
image: string | null;
|
|
1700
|
+
backupFile: string | null;
|
|
1701
|
+
backupPercent: number | null;
|
|
1702
|
+
error: string | null;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function RecreateSection({ agentName, agentBusy, onRecreated, onStarted }: {
|
|
1320
1706
|
agentName: string;
|
|
1707
|
+
/** A long operation is running on this agent. */
|
|
1708
|
+
agentBusy?: boolean;
|
|
1321
1709
|
/** The container was rebuilt — the parent refetches (new id, fresh image). */
|
|
1322
1710
|
onRecreated: () => void;
|
|
1711
|
+
/** The recreate job started — show the RECREATING chip at once. */
|
|
1712
|
+
onStarted?: () => void;
|
|
1323
1713
|
}) {
|
|
1324
|
-
const [
|
|
1714
|
+
const [job, setJob] = useState<RecreateJob | null>(null);
|
|
1715
|
+
const [starting, setStarting] = useState(false);
|
|
1325
1716
|
const [error, setError] = useState('');
|
|
1326
1717
|
const [toast, setToast] = useState<string | null>(null);
|
|
1327
1718
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
1719
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
1720
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
1721
|
+
const seenDone = useRef(false);
|
|
1328
1722
|
|
|
1329
1723
|
const showToast = (msg: string) => {
|
|
1330
1724
|
setToast(msg);
|
|
1331
1725
|
setTimeout(() => setToast(null), 5000);
|
|
1332
1726
|
};
|
|
1333
1727
|
|
|
1728
|
+
const stopPolling = useCallback(() => {
|
|
1729
|
+
if (pollRef.current) {
|
|
1730
|
+
clearInterval(pollRef.current);
|
|
1731
|
+
pollRef.current = null;
|
|
1732
|
+
}
|
|
1733
|
+
}, []);
|
|
1734
|
+
|
|
1735
|
+
/* Poll the job every 2s while it runs. The job is recorded in `agent_recreates`
|
|
1736
|
+
and the steps run in the Rev4a process, so a reload resumes from the row. */
|
|
1737
|
+
const startPolling = useCallback(() => {
|
|
1738
|
+
stopPolling();
|
|
1739
|
+
const controller = new AbortController();
|
|
1740
|
+
abortRef.current = controller;
|
|
1741
|
+
pollRef.current = setInterval(async () => {
|
|
1742
|
+
try {
|
|
1743
|
+
const res = await fetch(`/api/agents/${agentName}/recreate`, { signal: controller.signal });
|
|
1744
|
+
if (!res.ok) return;
|
|
1745
|
+
const data = await res.json();
|
|
1746
|
+
const current: RecreateJob | null = data.recreate ?? null;
|
|
1747
|
+
if (current && (current.status === 'backing_up' || current.status === 'recreating')) {
|
|
1748
|
+
setJob(current);
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
stopPolling();
|
|
1752
|
+
setJob(null);
|
|
1753
|
+
if (current?.status === 'done') {
|
|
1754
|
+
if (!seenDone.current) {
|
|
1755
|
+
seenDone.current = true;
|
|
1756
|
+
showToast('Container recreated successfully');
|
|
1757
|
+
onRecreated();
|
|
1758
|
+
}
|
|
1759
|
+
} else if (current?.status === 'failed' || current?.status === 'interrupted') {
|
|
1760
|
+
setError(current.error || 'Recreate failed');
|
|
1761
|
+
}
|
|
1762
|
+
} catch { /* aborted or transient network error — the next tick retries */ }
|
|
1763
|
+
}, 2000);
|
|
1764
|
+
}, [agentName, onRecreated, stopPolling]);
|
|
1765
|
+
|
|
1766
|
+
/* On mount: pick up a recreate that is already running. */
|
|
1767
|
+
useEffect(() => {
|
|
1768
|
+
let alive = true;
|
|
1769
|
+
void (async () => {
|
|
1770
|
+
try {
|
|
1771
|
+
const res = await fetch(`/api/agents/${agentName}/recreate`);
|
|
1772
|
+
if (res.ok && alive) {
|
|
1773
|
+
const data = await res.json();
|
|
1774
|
+
if (data.recreate && (data.recreate.status === 'backing_up' || data.recreate.status === 'recreating')) {
|
|
1775
|
+
setJob(data.recreate);
|
|
1776
|
+
startPolling();
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
} catch { /* ignore */ }
|
|
1780
|
+
})();
|
|
1781
|
+
return () => { alive = false; stopPolling(); };
|
|
1782
|
+
}, [agentName, startPolling, stopPolling]);
|
|
1783
|
+
|
|
1334
1784
|
const handleRecreate = async () => {
|
|
1335
1785
|
setShowConfirm(false);
|
|
1336
|
-
|
|
1786
|
+
setStarting(true);
|
|
1337
1787
|
setError('');
|
|
1338
1788
|
try {
|
|
1339
1789
|
const res = await fetch(`/api/agents/${agentName}/recreate`, { method: 'POST' });
|
|
1340
1790
|
const data = await res.json();
|
|
1341
|
-
if (data.
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1791
|
+
if (res.status === 202 && data.started) {
|
|
1792
|
+
seenDone.current = false;
|
|
1793
|
+
setJob({ status: 'backing_up', image: null, backupFile: null, backupPercent: null, error: null });
|
|
1794
|
+
onStarted?.();
|
|
1795
|
+
startPolling();
|
|
1346
1796
|
} else {
|
|
1347
1797
|
setError(data.error || 'Recreate failed');
|
|
1348
1798
|
}
|
|
1349
1799
|
} catch (e: unknown) {
|
|
1350
1800
|
setError((e as Error).message || 'Network error');
|
|
1351
1801
|
}
|
|
1352
|
-
|
|
1802
|
+
setStarting(false);
|
|
1353
1803
|
};
|
|
1354
1804
|
|
|
1805
|
+
const running = job !== null;
|
|
1806
|
+
|
|
1355
1807
|
return (
|
|
1356
1808
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1357
1809
|
<SectionHeader icon={<PackageIcon size={12} />} label="RECREATE" />
|
|
1358
1810
|
<div style={{ padding: '12px 14px' }}>
|
|
1359
|
-
{
|
|
1811
|
+
{running && (
|
|
1360
1812
|
<div style={{
|
|
1361
1813
|
padding: '8px 12px',
|
|
1362
1814
|
fontSize: 11,
|
|
@@ -1375,15 +1827,25 @@ function RecreateSection({ agentName, onRecreated }: {
|
|
|
1375
1827
|
borderRadius: '50%',
|
|
1376
1828
|
background: 'var(--violet)',
|
|
1377
1829
|
animation: 'pulse 1s infinite',
|
|
1830
|
+
flexShrink: 0,
|
|
1378
1831
|
}} />
|
|
1379
|
-
|
|
1832
|
+
<span style={{ flex: 1, minWidth: 0, wordBreak: 'break-all' }}>
|
|
1833
|
+
{job.status === 'backing_up' ? (
|
|
1834
|
+
<>
|
|
1835
|
+
Backing up {job.backupFile ?? '…'}
|
|
1836
|
+
<span style={{ whiteSpace: 'nowrap' }}>
|
|
1837
|
+
{' '}{job.backupPercent !== null ? `— ${job.backupPercent}% ` : ''}(the agent restarts when done)
|
|
1838
|
+
</span>
|
|
1839
|
+
</>
|
|
1840
|
+
) : 'Recreating container…'}
|
|
1841
|
+
</span>
|
|
1380
1842
|
</div>
|
|
1381
1843
|
)}
|
|
1382
1844
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
|
|
1383
|
-
Takes an automatic backup
|
|
1845
|
+
Takes an automatic cold backup — the agent stops for it — then rebuilds the container on the same OpenClaw version, keeping workspace, config, credentials and state. To move to a newer OpenClaw version, use Update above.
|
|
1384
1846
|
</div>
|
|
1385
|
-
<Button variant="secondary" size="sm"
|
|
1386
|
-
{
|
|
1847
|
+
<Button variant="secondary" size="sm" disabled={running || starting || !!agentBusy} onClick={() => setShowConfirm(true)}>
|
|
1848
|
+
{starting ? 'Starting…' : running ? 'Recreating…' : 'Recreate Container'}
|
|
1387
1849
|
</Button>
|
|
1388
1850
|
{error && <div style={{ fontSize: 11, color: '#ef4444', marginTop: 6 }}>{error}</div>}
|
|
1389
1851
|
</div>
|
|
@@ -1397,7 +1859,7 @@ function RecreateSection({ agentName, onRecreated }: {
|
|
|
1397
1859
|
onClose={() => setShowConfirm(false)}
|
|
1398
1860
|
onConfirm={handleRecreate}
|
|
1399
1861
|
title="Recreate Container"
|
|
1400
|
-
message={`Recreate container "${agentName}"?\n\nAn automatic backup is taken first
|
|
1862
|
+
message={`Recreate container "${agentName}"?\n\nAn automatic cold backup is taken first — the agent stops for it — then the container is rebuilt on the same image. Workspace, credentials, and state are preserved in the persistent volume. If the backup fails, the recreate is aborted and the agent is started again.`}
|
|
1401
1863
|
confirmLabel="Recreate"
|
|
1402
1864
|
cancelLabel="Cancel"
|
|
1403
1865
|
tone="warning"
|
|
@@ -1408,7 +1870,7 @@ function RecreateSection({ agentName, onRecreated }: {
|
|
|
1408
1870
|
|
|
1409
1871
|
/* ─── Channel section - Telegram ─── */
|
|
1410
1872
|
|
|
1411
|
-
function ChannelSection({ agentId,
|
|
1873
|
+
function ChannelSection({ agentId, displayName }: { agentId: string; displayName: string }) {
|
|
1412
1874
|
const [open, setOpen] = useState(false);
|
|
1413
1875
|
const [preview, setPreview] = useState<{ telegram: boolean } | null>(null);
|
|
1414
1876
|
const previewAbortRef = useRef<AbortController | null>(null);
|
|
@@ -1460,7 +1922,7 @@ function ChannelSection({ agentId, agentName }: { agentId: string; agentName: st
|
|
|
1460
1922
|
</div>
|
|
1461
1923
|
<ChannelManager
|
|
1462
1924
|
agentId={agentId}
|
|
1463
|
-
|
|
1925
|
+
displayName={displayName}
|
|
1464
1926
|
open={open}
|
|
1465
1927
|
onClose={() => { setOpen(false); doFetchPreview(); }}
|
|
1466
1928
|
/>
|