@flame0510/project-aether 1.2.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 +3 -1
- 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 +316 -0
- package/app/agents/PageClient.tsx +708 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +8 -21
- 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]/model/route.ts +113 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -187
- 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 -169
- 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 +91 -145
- 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/models-summary/route.ts +163 -0
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +37 -16
- package/app/api/gateway/agent/route.ts +37 -6
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +335 -76
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +253 -674
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +116 -50
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +110 -12
- package/docs/FRONTEND-ARCHITECTURE.md +31 -2
- package/docs/REV4A.md +93 -33
- package/docs/dev/API-REFERENCE.md +723 -178
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +250 -93
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +59 -28
- package/docs/rag/GLOSSARY.md +27 -16
- package/docs/rag/REV4A-OVERVIEW.md +37 -25
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
- package/instrumentation.ts +52 -1
- 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-readiness.ts +110 -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 +153 -64
- package/lib/cold-backup.ts +354 -0
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/model-catalogue.ts +140 -27
- package/lib/provider-balance.ts +33 -12
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
|
@@ -7,6 +7,10 @@ import { Button, ListItem, Badge, ConfirmModal, Input, type Tone } from '../comp
|
|
|
7
7
|
import PasswordInput from '../components/PasswordInput';
|
|
8
8
|
import ImageDownloadBanner from './ImageDownloadBanner';
|
|
9
9
|
import ChannelManager from './ChannelManager';
|
|
10
|
+
import ModelSection from './ModelSection';
|
|
11
|
+
import BrowserAccessSection, { OpenControlUiButton } from './BrowserAccessSection';
|
|
12
|
+
import UpdateSection from './UpdateSection';
|
|
13
|
+
import { validatePortInput } from '@/lib/agent-ports';
|
|
10
14
|
import {
|
|
11
15
|
InfoIcon, BracesIcon, KeyIcon, TrashIcon, TerminalIcon,
|
|
12
16
|
LinkIcon, ArchiveIcon, RotateIcon, PackageIcon,
|
|
@@ -43,27 +47,10 @@ interface Agent {
|
|
|
43
47
|
created: string | null;
|
|
44
48
|
env: string[];
|
|
45
49
|
controlPort: string | null;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* URL of the agent's OpenClaw Control UI.
|
|
52
|
-
*
|
|
53
|
-
* Composed here rather than on the server on purpose. The hostname the dashboard
|
|
54
|
-
* is currently being reached on is, by definition, one that resolves and routes
|
|
55
|
-
* for this user — `localhost` on a dev machine, a LAN address from a phone, the
|
|
56
|
-
* VPS IP, or a domain. The server can only guess, and it guessed badly: it shelled
|
|
57
|
-
* out to `curl icanhazip.com` on every call and, on macOS, ended up returning the
|
|
58
|
-
* machine's `.local` mDNS name.
|
|
59
|
-
*
|
|
60
|
-
* Returns null when the agent has no published port (nothing to link to) or when
|
|
61
|
-
* called during a server render, where `window` doesn't exist.
|
|
62
|
-
*/
|
|
63
|
-
function agentControlUrl(agent: Agent): string | null {
|
|
64
|
-
if (!agent.controlPort || typeof window === 'undefined') return null;
|
|
65
|
-
const base = `http://${window.location.hostname}:${agent.controlPort}`;
|
|
66
|
-
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;
|
|
67
54
|
}
|
|
68
55
|
|
|
69
56
|
type FilterState = 'all' | 'running' | 'exited';
|
|
@@ -174,6 +161,10 @@ function AgentListItem({
|
|
|
174
161
|
loading,
|
|
175
162
|
selected,
|
|
176
163
|
channelSummary,
|
|
164
|
+
modelSummary,
|
|
165
|
+
deviceSummary,
|
|
166
|
+
backupSummary,
|
|
167
|
+
busy,
|
|
177
168
|
onClick,
|
|
178
169
|
onLifecycle,
|
|
179
170
|
}: {
|
|
@@ -181,6 +172,14 @@ function AgentListItem({
|
|
|
181
172
|
loading: boolean;
|
|
182
173
|
selected?: boolean;
|
|
183
174
|
channelSummary: Record<string, { telegram: boolean }>;
|
|
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;
|
|
184
183
|
onClick: () => void;
|
|
185
184
|
onLifecycle: () => void;
|
|
186
185
|
}) {
|
|
@@ -189,7 +188,7 @@ function AgentListItem({
|
|
|
189
188
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
190
189
|
<ToggleSwitch
|
|
191
190
|
checked={agent.state === 'running'}
|
|
192
|
-
disabled={loading || agent.state === 'unknown'}
|
|
191
|
+
disabled={loading || agent.state === 'unknown' || !!busy}
|
|
193
192
|
onChange={() => { void onLifecycle(); }}
|
|
194
193
|
ariaLabel={agent.state === 'running' ? 'Stop container' : 'Start container'}
|
|
195
194
|
/>
|
|
@@ -200,20 +199,7 @@ function AgentListItem({
|
|
|
200
199
|
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
201
200
|
{loading && <span style={{ fontSize: 10, color: '#888' }}>...</span>}
|
|
202
201
|
{agent.state === 'running' && agent.controlPort && (
|
|
203
|
-
<
|
|
204
|
-
variant="primary"
|
|
205
|
-
size="sm"
|
|
206
|
-
// Gate on the port, resolve the URL on click: the condition stays
|
|
207
|
-
// safe to evaluate during a server render, the URL never is.
|
|
208
|
-
onClick={(e) => {
|
|
209
|
-
e.stopPropagation();
|
|
210
|
-
const url = agentControlUrl(agent);
|
|
211
|
-
if (url) window.open(url, '_blank');
|
|
212
|
-
}}
|
|
213
|
-
title="Open agent Control UI"
|
|
214
|
-
>
|
|
215
|
-
Open ↗
|
|
216
|
-
</Button>
|
|
202
|
+
<OpenControlUiButton agentId={agent.agentId} stopPropagation />
|
|
217
203
|
)}
|
|
218
204
|
</div>
|
|
219
205
|
</div>
|
|
@@ -221,16 +207,77 @@ function AgentListItem({
|
|
|
221
207
|
{agent.image?.includes('/') ? agent.image.split('/').pop() : agent.image}
|
|
222
208
|
{agent.ports && <span style={{ marginLeft: 8 }}>{agent.ports}</span>}
|
|
223
209
|
</div>
|
|
210
|
+
{(() => {
|
|
211
|
+
const m = modelSummary[agent.agentId];
|
|
212
|
+
if (!m || !m.primary) return null;
|
|
213
|
+
// Tone follows what the operator has to do about it: red means the agent
|
|
214
|
+
// will fail on its next turn, amber means it still works on a retired name.
|
|
215
|
+
// The red states differ in remedy — pick another model for the first two,
|
|
216
|
+
// run Sync All on the Gateway page for OUT OF SYNC.
|
|
217
|
+
const failing = m.health === 'missing' || m.health === 'disabled' || m.health === 'out-of-sync';
|
|
218
|
+
const tone = failing ? 'danger' : m.health === 'deprecated' ? 'warning' : undefined;
|
|
219
|
+
const chip = m.health === 'missing' ? 'NOT IN CATALOGUE'
|
|
220
|
+
: m.health === 'disabled' ? 'NOT ENABLED'
|
|
221
|
+
: m.health === 'out-of-sync' ? 'OUT OF SYNC'
|
|
222
|
+
: m.health === 'deprecated' ? 'DEPRECATED'
|
|
223
|
+
: m.fallbackCount === 0 ? 'NO FALLBACK' : null;
|
|
224
|
+
return (
|
|
225
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 4, flexWrap: 'wrap' }}>
|
|
226
|
+
<span style={{ fontSize: 11, color: '#888', wordBreak: 'break-all' }}>
|
|
227
|
+
{m.primary.replace(/^rev4a\//, '')}
|
|
228
|
+
{/* Only the first fallback: a list here would drown the row, and the
|
|
229
|
+
full set is editable in the detail panel anyway. */}
|
|
230
|
+
{m.firstFallback && (
|
|
231
|
+
<span style={{ color: '#666' }}>
|
|
232
|
+
{' ↳ '}{m.firstFallback.replace(/^rev4a\//, '')}
|
|
233
|
+
{m.fallbackCount > 1 && ` +${m.fallbackCount - 1}`}
|
|
234
|
+
</span>
|
|
235
|
+
)}
|
|
236
|
+
</span>
|
|
237
|
+
{chip && (
|
|
238
|
+
<Badge tone={(tone ?? 'neutral') as Tone}>
|
|
239
|
+
<span style={{ fontSize: 8, padding: '1px 6px' }}>{chip}</span>
|
|
240
|
+
</Badge>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
})()}
|
|
224
245
|
{channelSummary[agent.agentId]?.telegram && (
|
|
225
246
|
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
|
226
247
|
<Badge tone="success"><span style={{ fontSize: 8, padding: '1px 6px' }}>TG</span></Badge>
|
|
227
248
|
</div>
|
|
228
249
|
)}
|
|
229
|
-
{agent.
|
|
250
|
+
{(deviceSummary[agent.agentId]?.pending ?? 0) > 0 && (
|
|
230
251
|
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
|
231
|
-
<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>
|
|
232
257
|
</div>
|
|
233
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>
|
|
262
|
+
</div>
|
|
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
|
+
})()}
|
|
234
281
|
</ListItem>
|
|
235
282
|
);
|
|
236
283
|
}
|
|
@@ -245,6 +292,9 @@ export default function AgentsPageClient() {
|
|
|
245
292
|
const [isMobile, setIsMobile] = useState(false);
|
|
246
293
|
const [showList, setShowList] = useState(true);
|
|
247
294
|
const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
|
|
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 }>>({});
|
|
248
298
|
const [imageReady, setImageReady] = useState(true);
|
|
249
299
|
// null = not checked yet. Keeping "unknown" distinct from "none configured" is
|
|
250
300
|
// the whole point: `false` as the initial value makes an unanswered check look
|
|
@@ -258,6 +308,80 @@ export default function AgentsPageClient() {
|
|
|
258
308
|
const skipNextPoll = useRef(false);
|
|
259
309
|
/** Guards the un-awaited channel-summary fetch against out-of-order responses. */
|
|
260
310
|
const chSummarySeq = useRef(0);
|
|
311
|
+
const mdSummarySeq = useRef(0);
|
|
312
|
+
const dvSummarySeq = useRef(0);
|
|
313
|
+
const bkSummarySeq = useRef(0);
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Refresh the per-agent model rows that feed the card chips.
|
|
317
|
+
*
|
|
318
|
+
* Shared by the 15s poll and by a save in the detail panel: without the second
|
|
319
|
+
* caller the chip kept showing the previous model until the next poll, which
|
|
320
|
+
* reads as "the save did not work".
|
|
321
|
+
*
|
|
322
|
+
* Sequence-guarded because several can overlap; the last one issued wins.
|
|
323
|
+
*/
|
|
324
|
+
const refreshModelSummary = useCallback(async () => {
|
|
325
|
+
const seq = ++mdSummarySeq.current;
|
|
326
|
+
try {
|
|
327
|
+
const res = await fetch('/api/agents/models-summary');
|
|
328
|
+
if (!res.ok || seq !== mdSummarySeq.current) return;
|
|
329
|
+
const payload = await res.json();
|
|
330
|
+
const byId: Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }> = {};
|
|
331
|
+
for (const row of payload.agents ?? []) byId[row.agentId] = row;
|
|
332
|
+
setMdSummary(byId);
|
|
333
|
+
} catch { /* badges are optional */ }
|
|
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]);
|
|
261
385
|
useEffect(() => {
|
|
262
386
|
const check = () => setIsMobile(window.innerWidth < 768);
|
|
263
387
|
check();
|
|
@@ -290,6 +414,12 @@ export default function AgentsPageClient() {
|
|
|
290
414
|
setChSummary(await chRes.json());
|
|
291
415
|
} catch { /* badges are optional */ }
|
|
292
416
|
})();
|
|
417
|
+
|
|
418
|
+
// Same treatment for the model summary: it decorates rows, so it must not
|
|
419
|
+
// hold the list, and a slow earlier response must not overwrite a newer one.
|
|
420
|
+
void refreshModelSummary();
|
|
421
|
+
void refreshDeviceSummary();
|
|
422
|
+
void refreshBackupSummary();
|
|
293
423
|
} catch (e: any) {
|
|
294
424
|
setError(e.message || 'Loading error');
|
|
295
425
|
} finally {
|
|
@@ -402,7 +532,7 @@ export default function AgentsPageClient() {
|
|
|
402
532
|
}, [agents, filter]);
|
|
403
533
|
|
|
404
534
|
// Derive the selected agent from the live list rather than holding a snapshot.
|
|
405
|
-
// A snapshot goes stale on every 15s poll (status, ports,
|
|
535
|
+
// A snapshot goes stale on every 15s poll (status, ports, version) and
|
|
406
536
|
// survives deletion; deriving means a rename shows up on the next load() and a
|
|
407
537
|
// deleted agent can't leave a detail panel pointing at something that's gone.
|
|
408
538
|
const selected = useMemo(
|
|
@@ -511,13 +641,13 @@ export default function AgentsPageClient() {
|
|
|
511
641
|
{providersConfigured === false && <NoProviderBanner />}
|
|
512
642
|
{/* Scrollable content */}
|
|
513
643
|
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
|
514
|
-
<DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
|
|
644
|
+
<DetailPanel key={selected.agentId} agent={selected} busy={!!bkSummary[selected.agentId]} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} onAccessChanged={refreshDeviceSummary} onActivity={notifyActivity} />
|
|
515
645
|
</div>
|
|
516
646
|
</div>
|
|
517
647
|
);
|
|
518
648
|
}
|
|
519
649
|
|
|
520
|
-
// Mobile:
|
|
650
|
+
// Mobile: list
|
|
521
651
|
return (
|
|
522
652
|
<div style={{ flex: 1, background: 'var(--bg)', color: 'var(--text)', fontFamily: 'var(--font-mono-stack)', display: 'flex', flexDirection: 'column', }}>
|
|
523
653
|
<div style={{ height: 48, padding: '0 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
|
@@ -545,6 +675,10 @@ export default function AgentsPageClient() {
|
|
|
545
675
|
agent={agent}
|
|
546
676
|
loading={lifecycleLoading === agent.agentId}
|
|
547
677
|
channelSummary={chSummary}
|
|
678
|
+
modelSummary={mdSummary}
|
|
679
|
+
deviceSummary={dvSummary}
|
|
680
|
+
backupSummary={bkSummary}
|
|
681
|
+
busy={!!bkSummary[agent.agentId]}
|
|
548
682
|
onClick={() => selectAgent(agent)}
|
|
549
683
|
onLifecycle={() => handleLifecycle(agent)}
|
|
550
684
|
/>
|
|
@@ -590,6 +724,10 @@ export default function AgentsPageClient() {
|
|
|
590
724
|
agent={agent}
|
|
591
725
|
loading={lifecycleLoading === agent.agentId}
|
|
592
726
|
channelSummary={chSummary}
|
|
727
|
+
modelSummary={mdSummary}
|
|
728
|
+
deviceSummary={dvSummary}
|
|
729
|
+
backupSummary={bkSummary}
|
|
730
|
+
busy={!!bkSummary[agent.agentId]}
|
|
593
731
|
selected={isSelected}
|
|
594
732
|
onClick={() => setSelectedId(agent.agentId)}
|
|
595
733
|
onLifecycle={() => handleLifecycle(agent)}
|
|
@@ -604,7 +742,7 @@ export default function AgentsPageClient() {
|
|
|
604
742
|
// key: without it React reuses one DetailPanel instance across
|
|
605
743
|
// selections, so in-flight state (deleting, recreating, restarting,
|
|
606
744
|
// toasts, open confirms) leaks onto the next agent you click.
|
|
607
|
-
<DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
|
|
745
|
+
<DetailPanel key={selected.agentId} agent={selected} busy={!!bkSummary[selected.agentId]} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} onAccessChanged={refreshDeviceSummary} onActivity={notifyActivity} />
|
|
608
746
|
)}
|
|
609
747
|
</div>
|
|
610
748
|
</div>
|
|
@@ -613,37 +751,79 @@ export default function AgentsPageClient() {
|
|
|
613
751
|
}
|
|
614
752
|
|
|
615
753
|
// ─── Edit Agent modal ───
|
|
616
|
-
|
|
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 }: {
|
|
617
774
|
agent: Agent;
|
|
618
775
|
onClose: () => void;
|
|
619
776
|
/** Rename succeeded — the parent refetches so the new name propagates. */
|
|
620
777
|
onSaved: () => void;
|
|
778
|
+
/** The edit job started — show the EDITING chip at once. */
|
|
779
|
+
onStarted?: () => void;
|
|
621
780
|
}) {
|
|
622
|
-
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);
|
|
623
785
|
const [saving, setSaving] = useState(false);
|
|
624
786
|
const [error, setError] = useState('');
|
|
625
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
|
+
|
|
626
802
|
const handleSave = async () => {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
setError('Display name must not be empty');
|
|
803
|
+
if (nothingChanged) {
|
|
804
|
+
setError('Nothing to change');
|
|
630
805
|
return;
|
|
631
806
|
}
|
|
632
|
-
if (
|
|
633
|
-
|
|
807
|
+
if (nameError || portError) {
|
|
808
|
+
setError('');
|
|
634
809
|
return;
|
|
635
810
|
}
|
|
636
811
|
setSaving(true);
|
|
637
812
|
setError('');
|
|
638
813
|
try {
|
|
814
|
+
const body: { displayName?: string; portRange?: string } = {};
|
|
815
|
+
if (nameChanged) body.displayName = displayName.trim();
|
|
816
|
+
if (portsChanged) body.portRange = portRange.trim();
|
|
639
817
|
const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}`, {
|
|
640
818
|
method: 'PATCH',
|
|
641
819
|
headers: { 'Content-Type': 'application/json' },
|
|
642
|
-
body: JSON.stringify(
|
|
820
|
+
body: JSON.stringify(body),
|
|
643
821
|
});
|
|
644
822
|
const data = await res.json();
|
|
645
823
|
if (!res.ok) throw new Error(data.error || 'Failed to update');
|
|
646
|
-
//
|
|
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?.();
|
|
647
827
|
onSaved();
|
|
648
828
|
onClose();
|
|
649
829
|
} catch (e: unknown) {
|
|
@@ -679,13 +859,27 @@ function EditAgentModal({ agent, onClose, onSaved }: {
|
|
|
679
859
|
value={displayName}
|
|
680
860
|
onChange={(e) => { setDisplayName(e.target.value); setError(''); }}
|
|
681
861
|
placeholder="My Agent 🚀"
|
|
682
|
-
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}
|
|
683
873
|
disabled={saving}
|
|
684
874
|
/>
|
|
685
875
|
<div style={{ fontSize: 10, color: '#666', marginTop: 4 }}>
|
|
686
|
-
|
|
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.
|
|
687
877
|
</div>
|
|
688
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>}
|
|
689
883
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
690
884
|
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>Cancel</Button>
|
|
691
885
|
<Button variant="primary" size="sm" onClick={handleSave} disabled={saving}>
|
|
@@ -768,23 +962,106 @@ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void })
|
|
|
768
962
|
}
|
|
769
963
|
|
|
770
964
|
// ─── Detail panel (shared between mobile & desktop) ───
|
|
771
|
-
|
|
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 }: {
|
|
772
1036
|
agent: Agent;
|
|
773
1037
|
/** The agent is gone — the parent drops it from the list. */
|
|
774
1038
|
onDeleted: (agentId: string) => void;
|
|
775
1039
|
/** The agent changed in place (renamed, recreated) — refetch the list. */
|
|
776
1040
|
onChanged: () => void;
|
|
1041
|
+
/**
|
|
1042
|
+
* The model changed. Narrower than `onChanged` on purpose: only the card chip
|
|
1043
|
+
* needs refreshing, so this avoids refetching the whole list plus the channel
|
|
1044
|
+
* summary for a field the parent already knows how to reload on its own.
|
|
1045
|
+
*/
|
|
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;
|
|
777
1053
|
}) {
|
|
778
1054
|
const [deleting, setDeleting] = useState(false);
|
|
779
1055
|
const [deleteError, setDeleteError] = useState('');
|
|
780
1056
|
const [showConfig, setShowConfig] = useState(false);
|
|
781
1057
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
782
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);
|
|
783
1061
|
|
|
784
1062
|
// Safe to resolve during render: this panel only mounts once the agent list has
|
|
785
1063
|
// been fetched client-side, so it never runs in a server render. The helper
|
|
786
1064
|
// guards for a missing `window` regardless.
|
|
787
|
-
const controlUrl = agentControlUrl(agent);
|
|
788
1065
|
|
|
789
1066
|
const handleDelete = async () => {
|
|
790
1067
|
setDeleting(true);
|
|
@@ -816,7 +1093,7 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
|
816
1093
|
<div>
|
|
817
1094
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
818
1095
|
<span style={{ fontSize: 16, fontWeight: 500 }}>{agent.displayName || agent.name}</span>
|
|
819
|
-
<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'}>
|
|
820
1097
|
✎
|
|
821
1098
|
</Button>
|
|
822
1099
|
</div>
|
|
@@ -831,6 +1108,9 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
|
831
1108
|
</div>
|
|
832
1109
|
</div>
|
|
833
1110
|
|
|
1111
|
+
{/* Rename / ports rebuild, resumed on mount */}
|
|
1112
|
+
<EditBanner agentId={agent.agentId} onChanged={onChanged} ping={editPing} />
|
|
1113
|
+
|
|
834
1114
|
{/* Properties */}
|
|
835
1115
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
836
1116
|
<SectionHeader icon={<InfoIcon size={12} />} label="PROPERTIES" />
|
|
@@ -840,6 +1120,7 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
|
840
1120
|
['Container', agent.name],
|
|
841
1121
|
['Image', agent.image],
|
|
842
1122
|
['Tag', agent.imageTag],
|
|
1123
|
+
['OpenClaw', agent.openclawVersion || 'unknown'],
|
|
843
1124
|
['Template', agent.template || '-'],
|
|
844
1125
|
['State', agent.state],
|
|
845
1126
|
['Ports', agent.ports || '-'],
|
|
@@ -854,7 +1135,26 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
|
854
1135
|
</div>
|
|
855
1136
|
|
|
856
1137
|
{/* Channels - Telegram */}
|
|
857
|
-
<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>
|
|
1152
|
+
|
|
1153
|
+
{/* Model - primary + fallbacks (moved here from the Gateway page) */}
|
|
1154
|
+
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1155
|
+
<SectionHeader icon={<BracesIcon size={12} />} label="MODEL" />
|
|
1156
|
+
<ModelSection agentId={agent.agentId} onSaved={onModelSaved} agentBusy={busy} />
|
|
1157
|
+
</div>
|
|
858
1158
|
|
|
859
1159
|
{/* Config - View openclaw.json */}
|
|
860
1160
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
@@ -873,30 +1173,16 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
|
873
1173
|
<IntegrationSection agentName={agent.name} />
|
|
874
1174
|
|
|
875
1175
|
{/* Restart */}
|
|
876
|
-
<RestartSection agentName={agent.name} />
|
|
1176
|
+
<RestartSection agentName={agent.name} agentBusy={busy} />
|
|
877
1177
|
|
|
878
1178
|
{/* Backup - persistent volume backup & restore */}
|
|
879
|
-
<BackupSection agentName={agent.name} />
|
|
880
|
-
|
|
881
|
-
{/* Access - agent Control UI URL (token inline in link) */}
|
|
882
|
-
{controlUrl && (
|
|
883
|
-
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
884
|
-
<SectionHeader icon={<KeyIcon size={12} />} label="ACCESS" />
|
|
885
|
-
<div style={{ padding: '7px 14px', fontSize: 12 }}>
|
|
886
|
-
<div style={{ color: '#888', marginBottom: 2 }}>Control UI</div>
|
|
887
|
-
<a href={controlUrl} target="_blank" rel="noopener noreferrer"
|
|
888
|
-
style={{ color: '#60a5fa', textDecoration: 'underline', fontSize: 12, wordBreak: 'break-all' }}
|
|
889
|
-
>{controlUrl}</a>
|
|
890
|
-
|
|
891
|
-
</div>
|
|
892
|
-
</div>
|
|
893
|
-
)}
|
|
1179
|
+
<BackupSection agentName={agent.name} agentBusy={busy} onStarted={(kind) => onActivity(agent.agentId, kind)} />
|
|
894
1180
|
|
|
895
1181
|
{/* Config modal */}
|
|
896
1182
|
{showConfig && <ConfigModal agent={agent} onClose={() => setShowConfig(false)} />}
|
|
897
1183
|
|
|
898
1184
|
{/* Edit agent modal */}
|
|
899
|
-
{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'); }} />}
|
|
900
1186
|
|
|
901
1187
|
{/* Delete confirmation */}
|
|
902
1188
|
<ConfirmModal
|
|
@@ -911,15 +1197,14 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
|
911
1197
|
/>
|
|
912
1198
|
|
|
913
1199
|
{/* Recreate */}
|
|
914
|
-
{
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
</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')} />
|
|
920
1205
|
</div>
|
|
921
|
-
|
|
922
|
-
<RecreateSection agentName={agent.name} onRecreated={onChanged} />
|
|
1206
|
+
</div>
|
|
1207
|
+
<RecreateSection agentName={agent.name} agentBusy={busy} onRecreated={onChanged} onStarted={() => onActivity(agent.agentId, 'recreate')} />
|
|
923
1208
|
|
|
924
1209
|
{/* Delete */}
|
|
925
1210
|
<div style={{ border: '1px solid rgba(239,68,68,0.3)', borderRadius: 0, background: 'rgba(239,68,68,0.04)', overflow: 'hidden' }}>
|
|
@@ -1015,98 +1300,221 @@ function IntegrationSection({ agentName }: { agentName: string }) {
|
|
|
1015
1300
|
);
|
|
1016
1301
|
}
|
|
1017
1302
|
|
|
1018
|
-
/* ─── Backup section - persistent volume backup & restore ─── */
|
|
1303
|
+
/* ─── Backup section - persistent volume backup & restore (cold backup) ─── */
|
|
1019
1304
|
|
|
1020
|
-
|
|
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
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function BackupSection({ agentName, agentBusy, onStarted }: { agentName: string; agentBusy?: boolean; onStarted?: (kind: 'backup' | 'restore') => void }) {
|
|
1021
1320
|
const [backups, setBackups] = useState<{ name: string; size: string; date: string }[]>([]);
|
|
1022
1321
|
const [loading, setLoading] = useState(false);
|
|
1023
|
-
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);
|
|
1024
1326
|
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
|
1025
1327
|
const [restoreConfirm, setRestoreConfirm] = useState<string | null>(null);
|
|
1328
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
1329
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
1026
1330
|
|
|
1027
1331
|
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
|
1028
1332
|
setToast({ msg, type });
|
|
1029
1333
|
setTimeout(() => setToast(null), 4000);
|
|
1030
1334
|
};
|
|
1031
1335
|
|
|
1336
|
+
const stopPolling = useCallback(() => {
|
|
1337
|
+
if (pollRef.current) {
|
|
1338
|
+
clearInterval(pollRef.current);
|
|
1339
|
+
pollRef.current = null;
|
|
1340
|
+
}
|
|
1341
|
+
}, []);
|
|
1342
|
+
|
|
1032
1343
|
const loadBackups = useCallback(async () => {
|
|
1344
|
+
abortRef.current?.abort();
|
|
1345
|
+
const controller = new AbortController();
|
|
1346
|
+
abortRef.current = controller;
|
|
1033
1347
|
setLoading(true);
|
|
1034
1348
|
try {
|
|
1035
|
-
const res = await fetch(`/api/agents/${agentName}/backup
|
|
1349
|
+
const res = await fetch(`/api/agents/${agentName}/backup`, { signal: controller.signal });
|
|
1036
1350
|
if (res.ok) {
|
|
1037
1351
|
const data: { backups: { name: string; size: string; date: string }[] } = await res.json();
|
|
1038
1352
|
setBackups(data.backups ?? []);
|
|
1039
1353
|
}
|
|
1040
|
-
} catch { /*
|
|
1354
|
+
} catch { /* aborted or transient — the list stays as it is */ }
|
|
1041
1355
|
setLoading(false);
|
|
1042
1356
|
}, [agentName]);
|
|
1043
1357
|
|
|
1044
|
-
|
|
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]);
|
|
1045
1435
|
|
|
1046
1436
|
const handleBackup = async () => {
|
|
1047
|
-
|
|
1048
|
-
setBackups([]);
|
|
1437
|
+
setBusy({ kind: 'starting' });
|
|
1049
1438
|
try {
|
|
1050
|
-
const res = await fetch(`/api/agents/${agentName}/backup`, { method: 'POST' });
|
|
1439
|
+
const res = await fetch(`/api/agents/${agentName}/cold-backup`, { method: 'POST' });
|
|
1051
1440
|
const data = await res.json();
|
|
1052
|
-
if (data.
|
|
1053
|
-
|
|
1054
|
-
|
|
1441
|
+
if (res.status === 202 && data.started) {
|
|
1442
|
+
setBackup({ status: 'running', file: data.file, percent: null, error: null });
|
|
1443
|
+
onStarted?.('backup');
|
|
1444
|
+
startPolling();
|
|
1055
1445
|
} else {
|
|
1056
|
-
showToast(data.error || 'Backup failed', 'error');
|
|
1446
|
+
showToast(data.error || 'Backup failed to start', 'error');
|
|
1057
1447
|
}
|
|
1058
1448
|
} catch (e: unknown) {
|
|
1059
1449
|
showToast((e as Error).message, 'error');
|
|
1060
1450
|
}
|
|
1061
|
-
|
|
1451
|
+
setBusy(null);
|
|
1062
1452
|
};
|
|
1063
1453
|
|
|
1064
|
-
const
|
|
1065
|
-
|
|
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);
|
|
1066
1471
|
};
|
|
1067
1472
|
|
|
1068
1473
|
const executeRestore = async () => {
|
|
1069
1474
|
const file = restoreConfirm;
|
|
1070
1475
|
if (!file) return;
|
|
1071
1476
|
setRestoreConfirm(null);
|
|
1072
|
-
|
|
1073
|
-
setBackups([]);
|
|
1477
|
+
setBusy({ kind: 'restoring', file });
|
|
1074
1478
|
try {
|
|
1075
1479
|
const res = await fetch(`/api/agents/${agentName}/restore?file=${encodeURIComponent(file)}`, { method: 'POST' });
|
|
1076
1480
|
const data = await res.json();
|
|
1077
|
-
if (data.
|
|
1078
|
-
|
|
1079
|
-
|
|
1481
|
+
if (res.status === 202 && data.started) {
|
|
1482
|
+
setRestore({ status: 'restoring', file, error: null });
|
|
1483
|
+
onStarted?.('restore');
|
|
1484
|
+
startRestorePolling();
|
|
1080
1485
|
} else {
|
|
1081
|
-
showToast(data.error || 'Restore failed', 'error');
|
|
1486
|
+
showToast(data.error || 'Restore failed to start', 'error');
|
|
1082
1487
|
}
|
|
1083
1488
|
} catch (e: unknown) {
|
|
1084
1489
|
showToast((e as Error).message, 'error');
|
|
1085
1490
|
}
|
|
1086
|
-
|
|
1491
|
+
setBusy(null);
|
|
1087
1492
|
};
|
|
1088
1493
|
|
|
1089
1494
|
const handleDeleteBackup = async (file: string) => {
|
|
1090
|
-
|
|
1495
|
+
setBusy({ kind: 'deleting', file });
|
|
1091
1496
|
try {
|
|
1092
1497
|
const res = await fetch(`/api/agents/${agentName}/backup?file=${encodeURIComponent(file)}`, { method: 'DELETE' });
|
|
1093
1498
|
const data = await res.json().catch(() => ({}));
|
|
1094
1499
|
if (!res.ok || !data.success) {
|
|
1095
1500
|
showToast(data.error || 'Delete failed', 'error');
|
|
1096
1501
|
}
|
|
1097
|
-
setBackups([]);
|
|
1098
1502
|
await loadBackups();
|
|
1099
1503
|
} catch (e: unknown) {
|
|
1100
1504
|
showToast((e as Error).message, 'error');
|
|
1101
1505
|
}
|
|
1102
|
-
|
|
1506
|
+
setBusy(null);
|
|
1103
1507
|
};
|
|
1104
1508
|
|
|
1509
|
+
const running = backup?.status === 'running';
|
|
1510
|
+
const restoring = restore?.status === 'restoring';
|
|
1511
|
+
const actionsLocked = running || restoring || busy !== null || !!agentBusy;
|
|
1512
|
+
|
|
1105
1513
|
return (
|
|
1106
1514
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1107
1515
|
<SectionHeader icon={<ArchiveIcon size={12} />} label="BACKUP" />
|
|
1108
1516
|
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
1109
|
-
{
|
|
1517
|
+
{running && (
|
|
1110
1518
|
<div style={{
|
|
1111
1519
|
padding: '8px 12px',
|
|
1112
1520
|
fontSize: 11,
|
|
@@ -1124,50 +1532,99 @@ function BackupSection({ agentName }: { agentName: string }) {
|
|
|
1124
1532
|
borderRadius: '50%',
|
|
1125
1533
|
background: 'var(--violet)',
|
|
1126
1534
|
animation: 'pulse 1s infinite',
|
|
1535
|
+
flexShrink: 0,
|
|
1127
1536
|
}} />
|
|
1128
|
-
{
|
|
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>
|
|
1129
1556
|
</div>
|
|
1130
1557
|
)}
|
|
1131
|
-
{
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
)
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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>
|
|
1162
1619
|
</div>
|
|
1163
|
-
)}
|
|
1164
|
-
|
|
1165
|
-
<Button variant="secondary" size="sm" disabled={!!status} onClick={handleBackup}>
|
|
1166
|
-
Backup Now
|
|
1167
|
-
</Button>
|
|
1168
|
-
</div>
|
|
1169
|
-
</>
|
|
1620
|
+
))}
|
|
1621
|
+
</div>
|
|
1170
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>
|
|
1171
1628
|
</div>
|
|
1172
1629
|
{toast && (
|
|
1173
1630
|
<div style={{ padding: '8px 14px', borderTop: '1px solid var(--border)', fontSize: 11, color: toast.type === 'success' ? '#22c55e' : '#ef4444' }}>
|
|
@@ -1190,7 +1647,7 @@ function BackupSection({ agentName }: { agentName: string }) {
|
|
|
1190
1647
|
|
|
1191
1648
|
/* ─── Restart section - simple container restart ─── */
|
|
1192
1649
|
|
|
1193
|
-
function RestartSection({ agentName }: { agentName: string }) {
|
|
1650
|
+
function RestartSection({ agentName, agentBusy }: { agentName: string; agentBusy?: boolean }) {
|
|
1194
1651
|
const [restarting, setRestarting] = useState(false);
|
|
1195
1652
|
const [toast, setToast] = useState<string | null>(null);
|
|
1196
1653
|
|
|
@@ -1222,7 +1679,7 @@ function RestartSection({ agentName }: { agentName: string }) {
|
|
|
1222
1679
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
|
|
1223
1680
|
Restart the container without losing any data.
|
|
1224
1681
|
</div>
|
|
1225
|
-
<Button variant="secondary" size="sm" loading={restarting} disabled={restarting} onClick={handleRestart}>
|
|
1682
|
+
<Button variant="secondary" size="sm" loading={restarting} disabled={restarting || !!agentBusy} onClick={handleRestart}>
|
|
1226
1683
|
{restarting ? 'Restarting...' : 'Restart Container'}
|
|
1227
1684
|
</Button>
|
|
1228
1685
|
</div>
|
|
@@ -1237,47 +1694,121 @@ function RestartSection({ agentName }: { agentName: string }) {
|
|
|
1237
1694
|
|
|
1238
1695
|
/* ─── Recreate section - rebuild container from same image while keeping volume ─── */
|
|
1239
1696
|
|
|
1240
|
-
|
|
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 }: {
|
|
1241
1706
|
agentName: string;
|
|
1707
|
+
/** A long operation is running on this agent. */
|
|
1708
|
+
agentBusy?: boolean;
|
|
1242
1709
|
/** The container was rebuilt — the parent refetches (new id, fresh image). */
|
|
1243
1710
|
onRecreated: () => void;
|
|
1711
|
+
/** The recreate job started — show the RECREATING chip at once. */
|
|
1712
|
+
onStarted?: () => void;
|
|
1244
1713
|
}) {
|
|
1245
|
-
const [
|
|
1714
|
+
const [job, setJob] = useState<RecreateJob | null>(null);
|
|
1715
|
+
const [starting, setStarting] = useState(false);
|
|
1246
1716
|
const [error, setError] = useState('');
|
|
1247
1717
|
const [toast, setToast] = useState<string | null>(null);
|
|
1248
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);
|
|
1249
1722
|
|
|
1250
1723
|
const showToast = (msg: string) => {
|
|
1251
1724
|
setToast(msg);
|
|
1252
1725
|
setTimeout(() => setToast(null), 5000);
|
|
1253
1726
|
};
|
|
1254
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
|
+
|
|
1255
1784
|
const handleRecreate = async () => {
|
|
1256
1785
|
setShowConfirm(false);
|
|
1257
|
-
|
|
1786
|
+
setStarting(true);
|
|
1258
1787
|
setError('');
|
|
1259
1788
|
try {
|
|
1260
1789
|
const res = await fetch(`/api/agents/${agentName}/recreate`, { method: 'POST' });
|
|
1261
1790
|
const data = await res.json();
|
|
1262
|
-
if (data.
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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();
|
|
1267
1796
|
} else {
|
|
1268
1797
|
setError(data.error || 'Recreate failed');
|
|
1269
1798
|
}
|
|
1270
1799
|
} catch (e: unknown) {
|
|
1271
1800
|
setError((e as Error).message || 'Network error');
|
|
1272
1801
|
}
|
|
1273
|
-
|
|
1802
|
+
setStarting(false);
|
|
1274
1803
|
};
|
|
1275
1804
|
|
|
1805
|
+
const running = job !== null;
|
|
1806
|
+
|
|
1276
1807
|
return (
|
|
1277
1808
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
1278
1809
|
<SectionHeader icon={<PackageIcon size={12} />} label="RECREATE" />
|
|
1279
1810
|
<div style={{ padding: '12px 14px' }}>
|
|
1280
|
-
{
|
|
1811
|
+
{running && (
|
|
1281
1812
|
<div style={{
|
|
1282
1813
|
padding: '8px 12px',
|
|
1283
1814
|
fontSize: 11,
|
|
@@ -1296,15 +1827,25 @@ function RecreateSection({ agentName, onRecreated }: {
|
|
|
1296
1827
|
borderRadius: '50%',
|
|
1297
1828
|
background: 'var(--violet)',
|
|
1298
1829
|
animation: 'pulse 1s infinite',
|
|
1830
|
+
flexShrink: 0,
|
|
1299
1831
|
}} />
|
|
1300
|
-
|
|
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>
|
|
1301
1842
|
</div>
|
|
1302
1843
|
)}
|
|
1303
1844
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
|
|
1304
|
-
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.
|
|
1305
1846
|
</div>
|
|
1306
|
-
<Button variant="secondary" size="sm"
|
|
1307
|
-
{
|
|
1847
|
+
<Button variant="secondary" size="sm" disabled={running || starting || !!agentBusy} onClick={() => setShowConfirm(true)}>
|
|
1848
|
+
{starting ? 'Starting…' : running ? 'Recreating…' : 'Recreate Container'}
|
|
1308
1849
|
</Button>
|
|
1309
1850
|
{error && <div style={{ fontSize: 11, color: '#ef4444', marginTop: 6 }}>{error}</div>}
|
|
1310
1851
|
</div>
|
|
@@ -1318,7 +1859,7 @@ function RecreateSection({ agentName, onRecreated }: {
|
|
|
1318
1859
|
onClose={() => setShowConfirm(false)}
|
|
1319
1860
|
onConfirm={handleRecreate}
|
|
1320
1861
|
title="Recreate Container"
|
|
1321
|
-
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.`}
|
|
1322
1863
|
confirmLabel="Recreate"
|
|
1323
1864
|
cancelLabel="Cancel"
|
|
1324
1865
|
tone="warning"
|
|
@@ -1329,7 +1870,7 @@ function RecreateSection({ agentName, onRecreated }: {
|
|
|
1329
1870
|
|
|
1330
1871
|
/* ─── Channel section - Telegram ─── */
|
|
1331
1872
|
|
|
1332
|
-
function ChannelSection({ agentId,
|
|
1873
|
+
function ChannelSection({ agentId, displayName }: { agentId: string; displayName: string }) {
|
|
1333
1874
|
const [open, setOpen] = useState(false);
|
|
1334
1875
|
const [preview, setPreview] = useState<{ telegram: boolean } | null>(null);
|
|
1335
1876
|
const previewAbortRef = useRef<AbortController | null>(null);
|
|
@@ -1381,7 +1922,7 @@ function ChannelSection({ agentId, agentName }: { agentId: string; agentName: st
|
|
|
1381
1922
|
</div>
|
|
1382
1923
|
<ChannelManager
|
|
1383
1924
|
agentId={agentId}
|
|
1384
|
-
|
|
1925
|
+
displayName={displayName}
|
|
1385
1926
|
open={open}
|
|
1386
1927
|
onClose={() => { setOpen(false); doFetchPreview(); }}
|
|
1387
1928
|
/>
|