@swarmclawai/swarmclaw 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +41 -10
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +12 -1
  7. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  8. package/src/app/api/external-agents/[id]/route.ts +38 -6
  9. package/src/app/api/external-agents/route.ts +17 -1
  10. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  11. package/src/app/api/gateways/[id]/route.ts +53 -1
  12. package/src/app/api/gateways/route.ts +53 -0
  13. package/src/app/api/openclaw/deploy/route.ts +240 -0
  14. package/src/cli/index.js +53 -0
  15. package/src/cli/index.test.js +102 -0
  16. package/src/cli/spec.js +79 -0
  17. package/src/components/agents/agent-sheet.tsx +97 -19
  18. package/src/components/auth/setup-wizard.tsx +111 -54
  19. package/src/components/gateways/gateway-sheet.tsx +202 -10
  20. package/src/components/openclaw/openclaw-deploy-panel.tsx +1208 -0
  21. package/src/components/providers/provider-list.tsx +321 -22
  22. package/src/lib/server/agent-runtime-config.ts +142 -7
  23. package/src/lib/server/agent-thread-session.ts +9 -1
  24. package/src/lib/server/chat-execution.ts +8 -2
  25. package/src/lib/server/heartbeat-service.ts +5 -1
  26. package/src/lib/server/openclaw-deploy.test.ts +75 -0
  27. package/src/lib/server/openclaw-deploy.ts +1384 -0
  28. package/src/lib/server/orchestrator.ts +9 -0
  29. package/src/lib/server/queue.ts +45 -2
  30. package/src/lib/setup-defaults.ts +2 -2
  31. package/src/lib/validation/schemas.ts +9 -0
  32. package/src/types/index.ts +65 -0
@@ -0,0 +1,1208 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { api } from '@/lib/api-client'
5
+ import { copyTextToClipboard } from '@/lib/clipboard'
6
+
7
+ type RemoteTemplate = 'docker' | 'render' | 'fly' | 'railway'
8
+ type RemoteProvider = 'hetzner' | 'digitalocean' | 'vultr' | 'linode' | 'lightsail' | 'gcp' | 'azure' | 'oci' | 'generic'
9
+ type UseCaseTemplate = 'local-dev' | 'single-vps' | 'private-tailnet' | 'browser-heavy' | 'team-control'
10
+ type ExposurePreset = 'private-lan' | 'tailscale' | 'caddy' | 'nginx' | 'ssh-tunnel'
11
+
12
+ interface LocalDeployStatus {
13
+ running: boolean
14
+ processId: string | null
15
+ pid: number | null
16
+ port: number
17
+ endpoint: string
18
+ wsUrl: string
19
+ token: string | null
20
+ startedAt: number | null
21
+ tail: string
22
+ lastError: string | null
23
+ launchCommand: string
24
+ installCommand: string
25
+ }
26
+
27
+ interface RemoteDeployStatus {
28
+ active: boolean
29
+ processId: string | null
30
+ pid: number | null
31
+ action: string | null
32
+ target: string | null
33
+ startedAt: number | null
34
+ status: 'idle' | 'running' | 'exited' | 'killed' | 'failed' | 'timeout'
35
+ exitCode: number | null
36
+ tail: string
37
+ lastError: string | null
38
+ lastSummary: string | null
39
+ lastCommandPreview: string | null
40
+ lastBackupPath: string | null
41
+ }
42
+
43
+ interface DeployFile {
44
+ name: string
45
+ language: 'bash' | 'yaml' | 'env' | 'toml' | 'text'
46
+ content: string
47
+ }
48
+
49
+ interface DeployBundle {
50
+ template: RemoteTemplate
51
+ provider: RemoteProvider
52
+ providerLabel: string
53
+ useCase: UseCaseTemplate
54
+ exposure: ExposurePreset
55
+ title: string
56
+ summary: string
57
+ endpoint: string
58
+ wsUrl: string
59
+ token: string
60
+ runbook: string[]
61
+ files: DeployFile[]
62
+ }
63
+
64
+ interface DeployStatusResponse {
65
+ local: LocalDeployStatus
66
+ remote?: RemoteDeployStatus
67
+ }
68
+
69
+ interface DeployActionResponse {
70
+ ok: boolean
71
+ local?: LocalDeployStatus
72
+ token?: string
73
+ bundle?: DeployBundle
74
+ processId?: string | null
75
+ remote?: RemoteDeployStatus
76
+ summary?: string
77
+ commandPreview?: string
78
+ verify?: {
79
+ ok: boolean
80
+ endpoint: string
81
+ wsUrl: string
82
+ authProvided: boolean
83
+ models: string[]
84
+ error?: string
85
+ hint?: string
86
+ }
87
+ error?: string
88
+ }
89
+
90
+ interface ApplyPatch {
91
+ endpoint?: string
92
+ token?: string
93
+ name?: string
94
+ notes?: string
95
+ deployment?: {
96
+ method?: 'local' | 'bundle' | 'ssh' | 'imported' | null
97
+ provider?: string | null
98
+ remoteTarget?: RemoteTemplate | null
99
+ useCase?: UseCaseTemplate | null
100
+ exposure?: ExposurePreset | null
101
+ sshHost?: string | null
102
+ sshUser?: string | null
103
+ sshPort?: number | null
104
+ sshKeyPath?: string | null
105
+ sshTargetDir?: string | null
106
+ lastDeployAt?: number | null
107
+ lastDeployAction?: string | null
108
+ lastDeploySummary?: string | null
109
+ lastDeployProcessId?: string | null
110
+ lastVerifiedAt?: number | null
111
+ lastVerifiedOk?: boolean | null
112
+ lastVerifiedMessage?: string | null
113
+ lastBackupPath?: string | null
114
+ }
115
+ }
116
+
117
+ interface OpenClawDeployPanelProps {
118
+ endpoint?: string | null
119
+ token?: string | null
120
+ deployment?: ApplyPatch['deployment'] | null
121
+ suggestedName?: string | null
122
+ title?: string
123
+ description?: string
124
+ compact?: boolean
125
+ onApply?: (patch: ApplyPatch) => void | Promise<void>
126
+ }
127
+
128
+ const TEMPLATE_OPTIONS: Array<{
129
+ id: RemoteTemplate
130
+ label: string
131
+ detail: string
132
+ }> = [
133
+ {
134
+ id: 'docker',
135
+ label: 'VPS Smart Deploy',
136
+ detail: 'Official OpenClaw Docker image plus cloud-init for mainstream VPS hosts',
137
+ },
138
+ {
139
+ id: 'render',
140
+ label: 'Render',
141
+ detail: 'Managed HTTPS with a repo-backed Docker service',
142
+ },
143
+ {
144
+ id: 'fly',
145
+ label: 'Fly.io',
146
+ detail: 'Persistent remote gateway with Fly volumes and HTTPS',
147
+ },
148
+ {
149
+ id: 'railway',
150
+ label: 'Railway',
151
+ detail: 'Simple Docker deploy with volume-backed state',
152
+ },
153
+ ]
154
+
155
+ const PROVIDER_OPTIONS: Array<{
156
+ id: RemoteProvider
157
+ label: string
158
+ detail: string
159
+ }> = [
160
+ { id: 'hetzner', label: 'Hetzner', detail: 'Cheap always-on VPS' },
161
+ { id: 'digitalocean', label: 'DigitalOcean', detail: 'Droplet + user-data flow' },
162
+ { id: 'vultr', label: 'Vultr', detail: 'Cloud Compute startup script' },
163
+ { id: 'linode', label: 'Linode', detail: 'Simple Ubuntu VM path' },
164
+ { id: 'lightsail', label: 'Lightsail', detail: 'AWS-hosted simple VPS' },
165
+ { id: 'gcp', label: 'GCP', detail: 'Compute Engine VM' },
166
+ { id: 'azure', label: 'Azure', detail: 'Ubuntu VM custom data' },
167
+ { id: 'oci', label: 'OCI', detail: 'Oracle cloud-init bootstrap' },
168
+ { id: 'generic', label: 'Generic', detail: 'Any Ubuntu 24.04 host' },
169
+ ]
170
+
171
+ const USE_CASE_OPTIONS: Array<{
172
+ id: UseCaseTemplate
173
+ label: string
174
+ detail: string
175
+ }> = [
176
+ { id: 'local-dev', label: 'Local Dev', detail: 'Loopback-friendly defaults for one machine and quick setup.' },
177
+ { id: 'single-vps', label: 'Single VPS', detail: 'Balanced default for most public or private VPS installs.' },
178
+ { id: 'private-tailnet', label: 'Private Tailnet', detail: 'Keep the gateway private and expose it over a tailnet.' },
179
+ { id: 'browser-heavy', label: 'Browser Heavy', detail: 'Roomier defaults for browser-backed nodes and automation.' },
180
+ { id: 'team-control', label: 'Team Control', detail: 'Shared operator-friendly control plane defaults and backups.' },
181
+ ]
182
+
183
+ const EXPOSURE_OPTIONS: Array<{
184
+ id: ExposurePreset
185
+ label: string
186
+ detail: string
187
+ }> = [
188
+ { id: 'private-lan', label: 'Private LAN', detail: 'Expose on LAN only and rely on your own firewall rules.' },
189
+ { id: 'tailscale', label: 'Tailscale', detail: 'Loopback only plus a tailnet-facing Tailscale serve script.' },
190
+ { id: 'caddy', label: 'Caddy', detail: 'Bundled reverse proxy with simple HTTPS termination.' },
191
+ { id: 'nginx', label: 'Nginx', detail: 'Bundled reverse proxy config for teams with existing TLS handling.' },
192
+ { id: 'ssh-tunnel', label: 'SSH Tunnel', detail: 'Keep it private and access the gateway through SSH port-forwarding.' },
193
+ ]
194
+
195
+ function buildLocalRunCommand(port: number, token?: string | null): string {
196
+ const parts = ['npx', 'openclaw', 'gateway', 'run', '--allow-unconfigured', '--force', '--bind', 'loopback', '--port', String(port)]
197
+ if (token) parts.push('--auth', 'token', '--token', token)
198
+ return parts.join(' ')
199
+ }
200
+
201
+ function buildLocalInstallCommand(port: number, token?: string | null): string {
202
+ const parts = ['npx', 'openclaw', 'gateway', 'install', '--port', String(port)]
203
+ if (token) parts.push('--token', token)
204
+ return `${parts.join(' ')} && npx openclaw gateway start`
205
+ }
206
+
207
+ function parseMaybeUrl(value: string | null | undefined): URL | null {
208
+ const trimmed = typeof value === 'string' ? value.trim() : ''
209
+ if (!trimmed) return null
210
+ try {
211
+ return new URL(trimmed)
212
+ } catch {
213
+ try {
214
+ return new URL(`http://${trimmed}`)
215
+ } catch {
216
+ return null
217
+ }
218
+ }
219
+ }
220
+
221
+ function isLocalEndpoint(value: string | null | undefined): boolean {
222
+ const parsed = parseMaybeUrl(value)
223
+ if (!parsed) return false
224
+ const host = parsed.hostname.toLowerCase()
225
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0'
226
+ }
227
+
228
+ function inferPort(value: string | null | undefined, fallback = 18789): number {
229
+ const parsed = parseMaybeUrl(value)
230
+ if (!parsed?.port) return fallback
231
+ const port = Number.parseInt(parsed.port, 10)
232
+ return Number.isFinite(port) ? port : fallback
233
+ }
234
+
235
+ function inferRemoteTarget(value: string | null | undefined): string {
236
+ const parsed = parseMaybeUrl(value)
237
+ if (!parsed || isLocalEndpoint(value)) return ''
238
+ const base = `${parsed.protocol}//${parsed.host}`
239
+ return base.replace(/\/+$/, '')
240
+ }
241
+
242
+ function badgeTone(active: boolean): string {
243
+ return active
244
+ ? 'border-accent-bright/30 bg-accent-bright/10 text-accent-bright'
245
+ : 'border-white/[0.08] bg-white/[0.02] text-text-2 hover:bg-white/[0.05]'
246
+ }
247
+
248
+ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
249
+ const {
250
+ endpoint,
251
+ token,
252
+ deployment,
253
+ suggestedName,
254
+ title = 'Smart Deploy OpenClaw',
255
+ description = 'Launch a local gateway on this host or generate a remote bundle with opinionated defaults.',
256
+ compact = false,
257
+ onApply,
258
+ } = props
259
+
260
+ const [activeTab, setActiveTab] = useState<'local' | 'remote'>('local')
261
+ const [localStatus, setLocalStatus] = useState<LocalDeployStatus | null>(null)
262
+ const [remoteStatus, setRemoteStatus] = useState<RemoteDeployStatus | null>(null)
263
+ const [localPort, setLocalPort] = useState(() => inferPort(endpoint))
264
+ const [deployToken, setDeployToken] = useState(token || '')
265
+ const [remoteTarget, setRemoteTarget] = useState(() => inferRemoteTarget(endpoint))
266
+ const [remoteScheme, setRemoteScheme] = useState<'http' | 'https'>(() => (
267
+ typeof endpoint === 'string' && endpoint.trim().startsWith('http://') ? 'http' : 'https'
268
+ ))
269
+ const [remoteTemplate, setRemoteTemplate] = useState<RemoteTemplate>('docker')
270
+ const [remoteProvider, setRemoteProvider] = useState<RemoteProvider>('hetzner')
271
+ const [useCase, setUseCase] = useState<UseCaseTemplate>(() => deployment?.useCase || 'single-vps')
272
+ const [exposure, setExposure] = useState<ExposurePreset>(() => deployment?.exposure || 'caddy')
273
+ const [sshHost, setSshHost] = useState(() => deployment?.sshHost || inferRemoteTarget(endpoint))
274
+ const [sshUser, setSshUser] = useState(() => deployment?.sshUser || 'root')
275
+ const [sshPort, setSshPort] = useState(() => deployment?.sshPort || 22)
276
+ const [sshKeyPath, setSshKeyPath] = useState(() => deployment?.sshKeyPath || '')
277
+ const [sshTargetDir, setSshTargetDir] = useState(() => deployment?.sshTargetDir || '/opt/openclaw')
278
+ const [restoreBackupPath, setRestoreBackupPath] = useState(() => deployment?.lastBackupPath || '')
279
+ const [bundle, setBundle] = useState<DeployBundle | null>(null)
280
+ const [bundleFile, setBundleFile] = useState('')
281
+ const [loading, setLoading] = useState<'idle' | 'starting-local' | 'stopping-local' | 'restarting-local' | 'generating-bundle' | 'ssh-deploy' | 'verifying' | 'remote-action'>('idle')
282
+ const [message, setMessage] = useState('')
283
+ const [error, setError] = useState('')
284
+ const [copiedKey, setCopiedKey] = useState('')
285
+ const [commandPreview, setCommandPreview] = useState('')
286
+ const [verifySummary, setVerifySummary] = useState('')
287
+
288
+ useEffect(() => {
289
+ if (token && !deployToken) setDeployToken(token)
290
+ }, [token, deployToken])
291
+
292
+ useEffect(() => {
293
+ if (endpoint && isLocalEndpoint(endpoint)) {
294
+ setLocalPort(inferPort(endpoint))
295
+ setActiveTab('local')
296
+ } else if (endpoint && inferRemoteTarget(endpoint)) {
297
+ setRemoteTarget(inferRemoteTarget(endpoint))
298
+ setSshHost((current) => current || inferRemoteTarget(endpoint))
299
+ setActiveTab('remote')
300
+ }
301
+ }, [endpoint])
302
+
303
+ useEffect(() => {
304
+ if (!deployment) return
305
+ if (deployment.useCase) setUseCase(deployment.useCase)
306
+ if (deployment.exposure) setExposure(deployment.exposure)
307
+ if (deployment.sshHost) setSshHost(deployment.sshHost)
308
+ if (deployment.sshUser) setSshUser(deployment.sshUser)
309
+ if (deployment.sshPort) setSshPort(deployment.sshPort)
310
+ if (deployment.sshKeyPath) setSshKeyPath(deployment.sshKeyPath)
311
+ if (deployment.sshTargetDir) setSshTargetDir(deployment.sshTargetDir)
312
+ if (deployment.lastBackupPath) setRestoreBackupPath(deployment.lastBackupPath)
313
+ }, [deployment])
314
+
315
+ useEffect(() => {
316
+ let cancelled = false
317
+ api<DeployStatusResponse>('GET', '/openclaw/deploy')
318
+ .then((result) => {
319
+ if (!cancelled) {
320
+ setLocalStatus(result.local)
321
+ setRemoteStatus(result.remote || null)
322
+ if (result.local.token) {
323
+ setDeployToken((current) => current || result.local.token || '')
324
+ }
325
+ }
326
+ })
327
+ .catch(() => {})
328
+ return () => {
329
+ cancelled = true
330
+ }
331
+ }, [])
332
+
333
+ useEffect(() => {
334
+ if (!remoteStatus?.active) return
335
+ const timer = window.setInterval(() => {
336
+ api<DeployStatusResponse>('GET', '/openclaw/deploy')
337
+ .then((result) => {
338
+ setLocalStatus(result.local)
339
+ setRemoteStatus(result.remote || null)
340
+ })
341
+ .catch(() => {})
342
+ }, 2500)
343
+ return () => window.clearInterval(timer)
344
+ }, [remoteStatus?.active])
345
+
346
+ const selectedFile = useMemo(() => {
347
+ if (!bundle) return null
348
+ return bundle.files.find((file) => file.name === bundleFile) || bundle.files[0] || null
349
+ }, [bundle, bundleFile])
350
+ const localLaunchCommand = useMemo(() => {
351
+ const typedToken = deployToken.trim()
352
+ if (typedToken) return buildLocalRunCommand(localPort, typedToken)
353
+ if (localStatus?.launchCommand) return localStatus.launchCommand
354
+ return buildLocalRunCommand(localPort)
355
+ }, [deployToken, localPort, localStatus?.launchCommand])
356
+ const localInstallCommand = useMemo(() => {
357
+ const typedToken = deployToken.trim()
358
+ if (typedToken) return buildLocalInstallCommand(localPort, typedToken)
359
+ if (localStatus?.installCommand) return localStatus.installCommand
360
+ return buildLocalInstallCommand(localPort)
361
+ }, [deployToken, localPort, localStatus?.installCommand])
362
+
363
+ const showMessage = (next: string) => {
364
+ setMessage(next)
365
+ if (!next) return
366
+ window.setTimeout(() => {
367
+ setMessage((current) => (current === next ? '' : current))
368
+ }, 2200)
369
+ }
370
+
371
+ const onCopied = async (key: string, value: string) => {
372
+ const ok = await copyTextToClipboard(value)
373
+ if (!ok) return
374
+ setCopiedKey(key)
375
+ window.setTimeout(() => {
376
+ setCopiedKey((current) => (current === key ? '' : current))
377
+ }, 1200)
378
+ }
379
+
380
+ const applyDeploymentPatch = async (patch: ApplyPatch) => {
381
+ await Promise.resolve(onApply?.(patch))
382
+ }
383
+
384
+ const buildRemoteDeploymentPatch = (overrides?: Partial<NonNullable<ApplyPatch['deployment']>>): NonNullable<ApplyPatch['deployment']> => ({
385
+ method: overrides?.method || (remoteTemplate === 'docker' ? 'bundle' : 'bundle'),
386
+ provider: overrides?.provider || (remoteTemplate === 'docker' ? remoteProvider : remoteTemplate),
387
+ remoteTarget: overrides?.remoteTarget || remoteTemplate,
388
+ useCase,
389
+ exposure,
390
+ sshHost: sshHost.trim() || null,
391
+ sshUser: sshUser.trim() || null,
392
+ sshPort,
393
+ sshKeyPath: sshKeyPath.trim() || null,
394
+ sshTargetDir: sshTargetDir.trim() || null,
395
+ lastDeployAt: overrides?.lastDeployAt ?? null,
396
+ lastDeployAction: overrides?.lastDeployAction ?? null,
397
+ lastDeploySummary: overrides?.lastDeploySummary ?? null,
398
+ lastDeployProcessId: overrides?.lastDeployProcessId ?? null,
399
+ lastVerifiedAt: overrides?.lastVerifiedAt ?? null,
400
+ lastVerifiedOk: overrides?.lastVerifiedOk ?? null,
401
+ lastVerifiedMessage: overrides?.lastVerifiedMessage ?? null,
402
+ lastBackupPath: overrides?.lastBackupPath ?? deployment?.lastBackupPath ?? null,
403
+ })
404
+
405
+ const handleStartLocal = async () => {
406
+ setLoading('starting-local')
407
+ setError('')
408
+ setVerifySummary('')
409
+ try {
410
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
411
+ action: 'start-local',
412
+ port: localPort,
413
+ token: deployToken.trim() || undefined,
414
+ })
415
+ if (!result.ok || !result.local) throw new Error(result.error || 'Local OpenClaw deploy failed.')
416
+ setLocalStatus(result.local)
417
+ if (result.token) setDeployToken(result.token)
418
+ const verify = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
419
+ action: 'verify',
420
+ endpoint: result.local.endpoint,
421
+ token: result.token || deployToken || undefined,
422
+ }).catch(() => ({ ok: false } as DeployActionResponse))
423
+ if (verify.verify) {
424
+ setVerifySummary(verify.verify.ok
425
+ ? `Verified ${verify.verify.endpoint} with ${verify.verify.models.length} model${verify.verify.models.length === 1 ? '' : 's'}.`
426
+ : (verify.verify.error || verify.verify.hint || 'Verification failed.'))
427
+ }
428
+ await applyDeploymentPatch({
429
+ endpoint: result.local.endpoint,
430
+ token: result.token || deployToken,
431
+ name: suggestedName || `Local OpenClaw ${result.local.port}`,
432
+ notes: 'Managed by SwarmClaw local deploy.',
433
+ deployment: {
434
+ method: 'local',
435
+ provider: 'local',
436
+ useCase: 'local-dev',
437
+ exposure: 'private-lan',
438
+ lastDeployAt: Date.now(),
439
+ lastDeployAction: 'start-local',
440
+ lastDeploySummary: 'Managed local OpenClaw runtime started from SwarmClaw.',
441
+ lastVerifiedAt: verify.verify ? Date.now() : null,
442
+ lastVerifiedOk: verify.verify?.ok ?? null,
443
+ lastVerifiedMessage: verify.verify
444
+ ? (verify.verify.error || verify.verify.hint || 'Verified successfully.')
445
+ : null,
446
+ },
447
+ })
448
+ showMessage('Local OpenClaw started and applied to this connection.')
449
+ } catch (err: unknown) {
450
+ setError(err instanceof Error ? err.message : 'Local OpenClaw deploy failed.')
451
+ } finally {
452
+ setLoading('idle')
453
+ }
454
+ }
455
+
456
+ const handleStopLocal = async () => {
457
+ setLoading('stopping-local')
458
+ setError('')
459
+ try {
460
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', { action: 'stop-local' })
461
+ if (!result.ok || !result.local) throw new Error(result.error || 'Failed to stop local OpenClaw.')
462
+ setLocalStatus(result.local)
463
+ showMessage('Stopped managed local OpenClaw runtime.')
464
+ } catch (err: unknown) {
465
+ setError(err instanceof Error ? err.message : 'Failed to stop local OpenClaw.')
466
+ } finally {
467
+ setLoading('idle')
468
+ }
469
+ }
470
+
471
+ const handleRestartLocal = async () => {
472
+ setLoading('restarting-local')
473
+ setError('')
474
+ try {
475
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
476
+ action: 'restart-local',
477
+ port: localPort,
478
+ token: deployToken.trim() || undefined,
479
+ })
480
+ if (!result.ok || !result.local) throw new Error(result.error || 'Failed to restart local OpenClaw.')
481
+ setLocalStatus(result.local)
482
+ if (result.token) setDeployToken(result.token)
483
+ await applyDeploymentPatch({
484
+ endpoint: result.local.endpoint,
485
+ token: result.token || deployToken,
486
+ name: suggestedName || `Local OpenClaw ${result.local.port}`,
487
+ notes: 'Managed by SwarmClaw local deploy.',
488
+ deployment: {
489
+ method: 'local',
490
+ provider: 'local',
491
+ useCase: 'local-dev',
492
+ exposure: 'private-lan',
493
+ lastDeployAt: Date.now(),
494
+ lastDeployAction: 'restart-local',
495
+ lastDeploySummary: 'Managed local OpenClaw runtime restarted from SwarmClaw.',
496
+ },
497
+ })
498
+ showMessage('Restarted managed local OpenClaw runtime.')
499
+ } catch (err: unknown) {
500
+ setError(err instanceof Error ? err.message : 'Failed to restart local OpenClaw.')
501
+ } finally {
502
+ setLoading('idle')
503
+ }
504
+ }
505
+
506
+ const handleGenerateBundle = async () => {
507
+ setLoading('generating-bundle')
508
+ setError('')
509
+ try {
510
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
511
+ action: 'bundle',
512
+ template: remoteTemplate,
513
+ target: remoteTarget.trim(),
514
+ scheme: remoteScheme,
515
+ token: deployToken.trim() || undefined,
516
+ provider: remoteProvider,
517
+ useCase,
518
+ exposure,
519
+ })
520
+ if (!result.ok || !result.bundle) throw new Error(result.error || 'Failed to generate OpenClaw deploy bundle.')
521
+ setBundle(result.bundle)
522
+ setBundleFile(result.bundle.files[0]?.name || '')
523
+ setDeployToken(result.bundle.token)
524
+ await applyDeploymentPatch({
525
+ endpoint: result.bundle.endpoint,
526
+ token: result.bundle.token,
527
+ name: suggestedName || result.bundle.title,
528
+ notes: `OpenClaw remote deploy template: ${result.bundle.title}`,
529
+ deployment: buildRemoteDeploymentPatch({
530
+ method: 'bundle',
531
+ provider: remoteTemplate === 'docker' ? remoteProvider : remoteTemplate,
532
+ remoteTarget: remoteTemplate,
533
+ lastDeployAction: 'bundle',
534
+ lastDeploySummary: `Generated ${result.bundle.title} from SwarmClaw.`,
535
+ }),
536
+ })
537
+ showMessage('Remote bundle generated and applied to this connection.')
538
+ } catch (err: unknown) {
539
+ setError(err instanceof Error ? err.message : 'Failed to generate OpenClaw deploy bundle.')
540
+ } finally {
541
+ setLoading('idle')
542
+ }
543
+ }
544
+
545
+ const handleVerify = async (overrideEndpoint?: string | null, overrideToken?: string | null) => {
546
+ setLoading('verifying')
547
+ setError('')
548
+ try {
549
+ const endpointToVerify = (overrideEndpoint || bundle?.endpoint || endpoint || '').trim()
550
+ const tokenToVerify = (overrideToken || deployToken || '').trim()
551
+ if (!endpointToVerify) throw new Error('Set an OpenClaw endpoint before verifying.')
552
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
553
+ action: 'verify',
554
+ endpoint: endpointToVerify,
555
+ token: tokenToVerify || undefined,
556
+ })
557
+ if (!result.verify) throw new Error(result.error || 'Verification failed.')
558
+ const summary = result.verify.ok
559
+ ? `Verified ${result.verify.endpoint} with ${result.verify.models.length} model${result.verify.models.length === 1 ? '' : 's'}.`
560
+ : (result.verify.error || result.verify.hint || 'Verification failed.')
561
+ setVerifySummary(summary)
562
+ await applyDeploymentPatch({
563
+ endpoint: result.verify.endpoint,
564
+ token: tokenToVerify || undefined,
565
+ deployment: buildRemoteDeploymentPatch({
566
+ method: deployment?.method || (isLocalEndpoint(result.verify.endpoint) ? 'local' : 'bundle'),
567
+ lastVerifiedAt: Date.now(),
568
+ lastVerifiedOk: result.verify.ok,
569
+ lastVerifiedMessage: summary,
570
+ }),
571
+ })
572
+ showMessage(result.verify.ok ? 'OpenClaw verification passed.' : summary)
573
+ } catch (err: unknown) {
574
+ setError(err instanceof Error ? err.message : 'Verification failed.')
575
+ } finally {
576
+ setLoading('idle')
577
+ }
578
+ }
579
+
580
+ const handleSshDeploy = async () => {
581
+ setLoading('ssh-deploy')
582
+ setError('')
583
+ setVerifySummary('')
584
+ try {
585
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
586
+ action: 'ssh-deploy',
587
+ template: remoteTemplate,
588
+ target: remoteTarget.trim(),
589
+ scheme: remoteScheme,
590
+ token: deployToken.trim() || undefined,
591
+ provider: remoteProvider,
592
+ useCase,
593
+ exposure,
594
+ ssh: {
595
+ host: sshHost.trim(),
596
+ user: sshUser.trim() || undefined,
597
+ port: sshPort,
598
+ keyPath: sshKeyPath.trim() || undefined,
599
+ targetDir: sshTargetDir.trim() || undefined,
600
+ },
601
+ })
602
+ if (!result.ok) throw new Error(result.error || 'Failed to start SSH deploy.')
603
+ if (result.bundle) {
604
+ setBundle(result.bundle)
605
+ setBundleFile(result.bundle.files[0]?.name || '')
606
+ }
607
+ if (result.token) setDeployToken(result.token)
608
+ setRemoteStatus(result.remote || null)
609
+ setCommandPreview(result.commandPreview || '')
610
+ await applyDeploymentPatch({
611
+ endpoint: result.bundle?.endpoint || endpoint || undefined,
612
+ token: result.token || deployToken,
613
+ name: suggestedName || result.bundle?.title || `SSH OpenClaw ${sshHost.trim()}`,
614
+ notes: `Official OpenClaw deployed over SSH to ${sshHost.trim()}.`,
615
+ deployment: buildRemoteDeploymentPatch({
616
+ method: 'ssh',
617
+ provider: remoteProvider,
618
+ lastDeployAt: Date.now(),
619
+ lastDeployAction: 'ssh-deploy',
620
+ lastDeploySummary: result.summary || `Started SSH deploy to ${sshHost.trim()}.`,
621
+ lastDeployProcessId: result.processId || null,
622
+ }),
623
+ })
624
+ showMessage(result.summary || 'Started SSH deploy.')
625
+ } catch (err: unknown) {
626
+ setError(err instanceof Error ? err.message : 'Failed to start SSH deploy.')
627
+ } finally {
628
+ setLoading('idle')
629
+ }
630
+ }
631
+
632
+ const handleRemoteLifecycle = async (
633
+ action: 'remote-start' | 'remote-stop' | 'remote-restart' | 'remote-upgrade' | 'remote-backup' | 'remote-restore' | 'remote-rotate-token',
634
+ ) => {
635
+ setLoading('remote-action')
636
+ setError('')
637
+ try {
638
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
639
+ action,
640
+ token: action === 'remote-rotate-token' ? (deployToken.trim() || undefined) : undefined,
641
+ backupPath: action === 'remote-restore' ? (restoreBackupPath.trim() || undefined) : undefined,
642
+ ssh: {
643
+ host: sshHost.trim(),
644
+ user: sshUser.trim() || undefined,
645
+ port: sshPort,
646
+ keyPath: sshKeyPath.trim() || undefined,
647
+ targetDir: sshTargetDir.trim() || undefined,
648
+ },
649
+ })
650
+ if (!result.ok) throw new Error(result.error || 'Remote lifecycle action failed.')
651
+ if (result.token) setDeployToken(result.token)
652
+ setRemoteStatus(result.remote || null)
653
+ setCommandPreview(result.commandPreview || '')
654
+ if (result.remote?.lastBackupPath) {
655
+ setRestoreBackupPath(result.remote.lastBackupPath)
656
+ }
657
+ await applyDeploymentPatch({
658
+ token: result.token || undefined,
659
+ deployment: buildRemoteDeploymentPatch({
660
+ method: 'ssh',
661
+ provider: remoteProvider,
662
+ lastDeployAt: Date.now(),
663
+ lastDeployAction: action,
664
+ lastDeploySummary: result.summary || action,
665
+ lastDeployProcessId: result.processId || null,
666
+ lastBackupPath: action === 'remote-backup' || action === 'remote-restore'
667
+ ? (result.remote?.lastBackupPath || restoreBackupPath.trim() || null)
668
+ : undefined,
669
+ }),
670
+ })
671
+ showMessage(result.summary || 'Remote lifecycle action started.')
672
+ } catch (err: unknown) {
673
+ setError(err instanceof Error ? err.message : 'Remote lifecycle action failed.')
674
+ } finally {
675
+ setLoading('idle')
676
+ }
677
+ }
678
+
679
+ return (
680
+ <div className={`rounded-[16px] border border-white/[0.08] bg-surface ${compact ? 'p-4' : 'p-5'} text-left`}>
681
+ <div className="flex flex-wrap items-start justify-between gap-3">
682
+ <div>
683
+ <div className="font-display text-[16px] font-700 text-text">{title}</div>
684
+ <p className="mt-1 text-[12px] text-text-3 leading-relaxed">{description}</p>
685
+ </div>
686
+ <div className="flex items-center gap-2">
687
+ <button
688
+ type="button"
689
+ onClick={() => setActiveTab('local')}
690
+ className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(activeTab === 'local')}`}
691
+ >
692
+ Local
693
+ </button>
694
+ <button
695
+ type="button"
696
+ onClick={() => setActiveTab('remote')}
697
+ className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(activeTab === 'remote')}`}
698
+ >
699
+ Remote
700
+ </button>
701
+ </div>
702
+ </div>
703
+
704
+ {activeTab === 'local' && (
705
+ <div className="mt-4 space-y-4">
706
+ <div className="grid gap-3 md:grid-cols-[120px_1fr]">
707
+ <div>
708
+ <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Port</label>
709
+ <input
710
+ type="number"
711
+ value={localPort}
712
+ onChange={(e) => setLocalPort(Number.parseInt(e.target.value, 10) || 18789)}
713
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text outline-none focus:border-accent-bright/30"
714
+ />
715
+ </div>
716
+ <div>
717
+ <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Gateway token</label>
718
+ <input
719
+ type="text"
720
+ value={deployToken}
721
+ onChange={(e) => setDeployToken(e.target.value)}
722
+ placeholder="Leave blank to generate a secure token"
723
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
724
+ />
725
+ </div>
726
+ </div>
727
+
728
+ <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-3">
729
+ <div className="flex items-center justify-between gap-3">
730
+ <div>
731
+ <div className="text-[13px] font-600 text-text">Managed local runtime</div>
732
+ <div className="mt-1 text-[12px] text-text-3">
733
+ One-click bring-up on the same machine running SwarmClaw. Good for quickstarts and non-technical local installs.
734
+ </div>
735
+ </div>
736
+ <div className={`rounded-full px-2.5 py-1 text-[10px] font-700 uppercase tracking-[0.08em] ${
737
+ localStatus?.running
738
+ ? 'bg-emerald-500/10 text-emerald-300'
739
+ : 'bg-white/[0.05] text-text-3'
740
+ }`}>
741
+ {localStatus?.running ? 'running' : 'idle'}
742
+ </div>
743
+ </div>
744
+
745
+ <div className="mt-3 flex flex-wrap gap-2">
746
+ <button
747
+ type="button"
748
+ onClick={handleStartLocal}
749
+ disabled={loading !== 'idle'}
750
+ className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-700 text-white border-none cursor-pointer hover:brightness-110 transition-all disabled:opacity-40"
751
+ >
752
+ {loading === 'starting-local' ? 'Starting…' : 'Deploy on This Host'}
753
+ </button>
754
+ {localStatus?.running && (
755
+ <button
756
+ type="button"
757
+ onClick={handleRestartLocal}
758
+ disabled={loading !== 'idle'}
759
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
760
+ >
761
+ {loading === 'restarting-local' ? 'Restarting…' : 'Restart'}
762
+ </button>
763
+ )}
764
+ {localStatus?.running && (
765
+ <button
766
+ type="button"
767
+ onClick={() => void handleVerify(localStatus.endpoint, deployToken || localStatus.token)}
768
+ disabled={loading !== 'idle'}
769
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
770
+ >
771
+ {loading === 'verifying' ? 'Verifying…' : 'Verify'}
772
+ </button>
773
+ )}
774
+ {localStatus?.running && (
775
+ <button
776
+ type="button"
777
+ onClick={handleStopLocal}
778
+ disabled={loading !== 'idle'}
779
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
780
+ >
781
+ {loading === 'stopping-local' ? 'Stopping…' : 'Stop'}
782
+ </button>
783
+ )}
784
+ <button
785
+ type="button"
786
+ onClick={() => onCopied('local-launch', localLaunchCommand)}
787
+ disabled={!localLaunchCommand}
788
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
789
+ >
790
+ {copiedKey === 'local-launch' ? 'Copied launch' : 'Copy launch cmd'}
791
+ </button>
792
+ <button
793
+ type="button"
794
+ onClick={() => onCopied('local-install', localInstallCommand)}
795
+ disabled={!localInstallCommand}
796
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
797
+ >
798
+ {copiedKey === 'local-install' ? 'Copied install' : 'Copy service cmd'}
799
+ </button>
800
+ <button
801
+ type="button"
802
+ onClick={() => onCopied('local-token', deployToken.trim() || localStatus?.token || '')}
803
+ disabled={!deployToken.trim() && !localStatus?.token}
804
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
805
+ >
806
+ {copiedKey === 'local-token' ? 'Copied token' : 'Copy token'}
807
+ </button>
808
+ </div>
809
+
810
+ {localStatus && (
811
+ <div className="mt-3 grid gap-3 md:grid-cols-2">
812
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
813
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Endpoint</div>
814
+ <div className="mt-1 text-[12px] text-text-2 font-mono break-all">{localStatus.endpoint}</div>
815
+ </div>
816
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
817
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Persistent install</div>
818
+ <div className="mt-1 text-[12px] text-text-3 leading-relaxed">
819
+ For a durable OS service, use the generated install command after the quick deploy works.
820
+ </div>
821
+ </div>
822
+ {verifySummary && (
823
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2 md:col-span-2">
824
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Verification</div>
825
+ <div className="mt-1 text-[12px] text-text-2 leading-relaxed">{verifySummary}</div>
826
+ </div>
827
+ )}
828
+ </div>
829
+ )}
830
+
831
+ {!!localStatus?.tail && (
832
+ <pre className="mt-3 overflow-x-auto rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-2 text-[11px] text-text-2/80 whitespace-pre-wrap">
833
+ {localStatus.tail}
834
+ </pre>
835
+ )}
836
+ </div>
837
+ </div>
838
+ )}
839
+
840
+ {activeTab === 'remote' && (
841
+ <div className="mt-4 space-y-4">
842
+ <div className="grid gap-3 md:grid-cols-[1fr_120px]">
843
+ <div>
844
+ <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Public host or URL</label>
845
+ <input
846
+ type="text"
847
+ value={remoteTarget}
848
+ onChange={(e) => setRemoteTarget(e.target.value)}
849
+ placeholder="openclaw.example.com or https://openclaw.example.com"
850
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
851
+ />
852
+ </div>
853
+ <div>
854
+ <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Scheme</label>
855
+ <select
856
+ value={remoteScheme}
857
+ onChange={(e) => setRemoteScheme(e.target.value === 'http' ? 'http' : 'https')}
858
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text outline-none focus:border-accent-bright/30"
859
+ >
860
+ <option value="https">https</option>
861
+ <option value="http">http</option>
862
+ </select>
863
+ </div>
864
+ </div>
865
+
866
+ <div>
867
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Deploy target</div>
868
+ <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
869
+ {TEMPLATE_OPTIONS.map((option) => (
870
+ <button
871
+ key={option.id}
872
+ type="button"
873
+ onClick={() => setRemoteTemplate(option.id)}
874
+ className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(remoteTemplate === option.id)}`}
875
+ >
876
+ <div className="text-[13px] font-700">{option.label}</div>
877
+ <div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
878
+ </button>
879
+ ))}
880
+ </div>
881
+ </div>
882
+
883
+ {remoteTemplate === 'docker' && (
884
+ <div className="space-y-4">
885
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">VPS provider</div>
886
+ <div className="grid gap-2 md:grid-cols-3 xl:grid-cols-5">
887
+ {PROVIDER_OPTIONS.map((option) => (
888
+ <button
889
+ key={option.id}
890
+ type="button"
891
+ onClick={() => setRemoteProvider(option.id)}
892
+ className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(remoteProvider === option.id)}`}
893
+ >
894
+ <div className="text-[13px] font-700">{option.label}</div>
895
+ <div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
896
+ </button>
897
+ ))}
898
+ </div>
899
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
900
+ SwarmClaw generates a provider-specific runbook plus a cloud-init quickstart, but the runtime itself still comes from the official OpenClaw Docker image.
901
+ </p>
902
+
903
+ <div>
904
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Use case preset</div>
905
+ <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
906
+ {USE_CASE_OPTIONS.map((option) => (
907
+ <button
908
+ key={option.id}
909
+ type="button"
910
+ onClick={() => setUseCase(option.id)}
911
+ className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(useCase === option.id)}`}
912
+ >
913
+ <div className="text-[13px] font-700">{option.label}</div>
914
+ <div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
915
+ </button>
916
+ ))}
917
+ </div>
918
+ </div>
919
+
920
+ <div>
921
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Safe exposure preset</div>
922
+ <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
923
+ {EXPOSURE_OPTIONS.map((option) => (
924
+ <button
925
+ key={option.id}
926
+ type="button"
927
+ onClick={() => setExposure(option.id)}
928
+ className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(exposure === option.id)}`}
929
+ >
930
+ <div className="text-[13px] font-700">{option.label}</div>
931
+ <div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
932
+ </button>
933
+ ))}
934
+ </div>
935
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
936
+ Smart Deploy keeps the OpenClaw runtime official-only and generates the surrounding exposure config in-house so operators do not need third-party deploy services.
937
+ </p>
938
+ </div>
939
+
940
+ <div className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-4">
941
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-3">In-House SSH Deploy</div>
942
+ <div className="grid gap-3 md:grid-cols-2">
943
+ <input
944
+ type="text"
945
+ value={sshHost}
946
+ onChange={(e) => setSshHost(e.target.value)}
947
+ placeholder="gateway.your-vps.com"
948
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
949
+ />
950
+ <input
951
+ type="text"
952
+ value={sshUser}
953
+ onChange={(e) => setSshUser(e.target.value)}
954
+ placeholder="root"
955
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
956
+ />
957
+ <input
958
+ type="number"
959
+ value={sshPort}
960
+ onChange={(e) => setSshPort(Number.parseInt(e.target.value, 10) || 22)}
961
+ placeholder="22"
962
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
963
+ />
964
+ <input
965
+ type="text"
966
+ value={sshKeyPath}
967
+ onChange={(e) => setSshKeyPath(e.target.value)}
968
+ placeholder="~/.ssh/id_ed25519"
969
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
970
+ />
971
+ </div>
972
+ <input
973
+ type="text"
974
+ value={sshTargetDir}
975
+ onChange={(e) => setSshTargetDir(e.target.value)}
976
+ placeholder="/opt/openclaw"
977
+ className="mt-3 w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
978
+ />
979
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
980
+ SwarmClaw will push the generated official-image bundle to this host over SSH and run the bootstrap there. This stays inside your own infra and does not rely on outside OpenClaw deployers.
981
+ </p>
982
+ </div>
983
+ </div>
984
+ )}
985
+
986
+ <div className="flex flex-wrap gap-2">
987
+ <button
988
+ type="button"
989
+ onClick={handleGenerateBundle}
990
+ disabled={loading !== 'idle'}
991
+ className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-700 text-white border-none cursor-pointer hover:brightness-110 transition-all disabled:opacity-40"
992
+ >
993
+ {loading === 'generating-bundle' ? 'Generating…' : 'Generate Bundle'}
994
+ </button>
995
+ {remoteTemplate === 'docker' && (
996
+ <button
997
+ type="button"
998
+ onClick={handleSshDeploy}
999
+ disabled={loading !== 'idle' || !sshHost.trim()}
1000
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1001
+ >
1002
+ {loading === 'ssh-deploy' ? 'Deploying…' : 'Deploy Over SSH'}
1003
+ </button>
1004
+ )}
1005
+ <button
1006
+ type="button"
1007
+ onClick={() => void handleVerify(bundle?.endpoint || endpoint || remoteTarget, deployToken)}
1008
+ disabled={loading !== 'idle' || (!bundle?.endpoint && !endpoint && !remoteTarget.trim())}
1009
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1010
+ >
1011
+ {loading === 'verifying' ? 'Verifying…' : 'Verify Endpoint'}
1012
+ </button>
1013
+ {bundle && (
1014
+ <button
1015
+ type="button"
1016
+ onClick={() => onCopied('remote-token', bundle.token)}
1017
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all"
1018
+ >
1019
+ {copiedKey === 'remote-token' ? 'Copied token' : 'Copy token'}
1020
+ </button>
1021
+ )}
1022
+ </div>
1023
+
1024
+ {remoteTemplate === 'docker' && sshHost.trim() && (
1025
+ <div className="space-y-3">
1026
+ <div className="flex flex-wrap gap-2">
1027
+ <button
1028
+ type="button"
1029
+ onClick={() => void handleRemoteLifecycle('remote-start')}
1030
+ disabled={loading !== 'idle'}
1031
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1032
+ >
1033
+ Start
1034
+ </button>
1035
+ <button
1036
+ type="button"
1037
+ onClick={() => void handleRemoteLifecycle('remote-restart')}
1038
+ disabled={loading !== 'idle'}
1039
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1040
+ >
1041
+ Restart
1042
+ </button>
1043
+ <button
1044
+ type="button"
1045
+ onClick={() => void handleRemoteLifecycle('remote-upgrade')}
1046
+ disabled={loading !== 'idle'}
1047
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1048
+ >
1049
+ Upgrade
1050
+ </button>
1051
+ <button
1052
+ type="button"
1053
+ onClick={() => void handleRemoteLifecycle('remote-backup')}
1054
+ disabled={loading !== 'idle'}
1055
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1056
+ >
1057
+ Backup
1058
+ </button>
1059
+ <button
1060
+ type="button"
1061
+ onClick={() => void handleRemoteLifecycle('remote-rotate-token')}
1062
+ disabled={loading !== 'idle'}
1063
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1064
+ >
1065
+ Rotate token
1066
+ </button>
1067
+ <button
1068
+ type="button"
1069
+ onClick={() => void handleRemoteLifecycle('remote-stop')}
1070
+ disabled={loading !== 'idle'}
1071
+ className="rounded-[10px] border border-red-400/20 bg-red-400/[0.06] px-3 py-1.5 text-[11px] font-700 text-red-300 cursor-pointer hover:bg-red-400/[0.1] transition-all disabled:opacity-40"
1072
+ >
1073
+ Stop
1074
+ </button>
1075
+ </div>
1076
+ <div className="grid gap-2 md:grid-cols-[1fr_auto]">
1077
+ <input
1078
+ type="text"
1079
+ value={restoreBackupPath}
1080
+ onChange={(e) => setRestoreBackupPath(e.target.value)}
1081
+ placeholder="/opt/openclaw/backups/openclaw-backup-123456789.tgz"
1082
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
1083
+ />
1084
+ <button
1085
+ type="button"
1086
+ onClick={() => void handleRemoteLifecycle('remote-restore')}
1087
+ disabled={loading !== 'idle' || !restoreBackupPath.trim()}
1088
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1089
+ >
1090
+ Restore backup
1091
+ </button>
1092
+ </div>
1093
+ </div>
1094
+ )}
1095
+
1096
+ {(verifySummary || commandPreview || remoteStatus) && (
1097
+ <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
1098
+ {verifySummary && (
1099
+ <div className="text-[12px] text-text-2 leading-relaxed">{verifySummary}</div>
1100
+ )}
1101
+ {remoteStatus && (
1102
+ <div className="mt-3 grid gap-3 md:grid-cols-3">
1103
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
1104
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Remote action</div>
1105
+ <div className="mt-1 text-[12px] text-text-2">{remoteStatus.action || remoteStatus.lastSummary || 'Idle'}</div>
1106
+ </div>
1107
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
1108
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Target</div>
1109
+ <div className="mt-1 text-[12px] text-text-2 font-mono break-all">{remoteStatus.target || sshHost || 'n/a'}</div>
1110
+ </div>
1111
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
1112
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Status</div>
1113
+ <div className="mt-1 text-[12px] text-text-2">{remoteStatus.status}</div>
1114
+ </div>
1115
+ </div>
1116
+ )}
1117
+ {(commandPreview || remoteStatus?.lastCommandPreview) && (
1118
+ <pre className="mt-3 overflow-x-auto rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-3 text-[11px] text-text-2/80 whitespace-pre-wrap">
1119
+ {commandPreview || remoteStatus?.lastCommandPreview}
1120
+ </pre>
1121
+ )}
1122
+ {!!remoteStatus?.tail && (
1123
+ <pre className="mt-3 overflow-x-auto rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-3 text-[11px] text-text-2/80 whitespace-pre-wrap">
1124
+ {remoteStatus.tail}
1125
+ </pre>
1126
+ )}
1127
+ {remoteStatus?.lastBackupPath && (
1128
+ <div className="mt-3 text-[12px] text-text-3">
1129
+ Last backup path: <code className="text-text-2">{remoteStatus.lastBackupPath}</code>
1130
+ </div>
1131
+ )}
1132
+ </div>
1133
+ )}
1134
+
1135
+ {bundle && (
1136
+ <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
1137
+ <div className="flex flex-wrap items-start justify-between gap-3">
1138
+ <div>
1139
+ <div className="text-[14px] font-700 text-text">{bundle.title}</div>
1140
+ <p className="mt-1 text-[12px] text-text-3 leading-relaxed">{bundle.summary}</p>
1141
+ </div>
1142
+ <div className="grid gap-2 md:grid-cols-2">
1143
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
1144
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Endpoint</div>
1145
+ <div className="mt-1 text-[11px] font-mono text-text-2 break-all">{bundle.endpoint}</div>
1146
+ </div>
1147
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
1148
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Host path</div>
1149
+ <div className="mt-1 text-[11px] text-text-2">{bundle.providerLabel}</div>
1150
+ </div>
1151
+ </div>
1152
+ </div>
1153
+
1154
+ <div className="mt-3 grid gap-2">
1155
+ {bundle.runbook.map((step, index) => (
1156
+ <div key={`${bundle.template}:${index}`} className="text-[12px] text-text-2 leading-relaxed">
1157
+ {index + 1}. {step}
1158
+ </div>
1159
+ ))}
1160
+ </div>
1161
+
1162
+ <div className="mt-4 flex flex-wrap gap-2">
1163
+ {bundle.files.map((file) => (
1164
+ <button
1165
+ key={file.name}
1166
+ type="button"
1167
+ onClick={() => setBundleFile(file.name)}
1168
+ className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(selectedFile?.name === file.name)}`}
1169
+ >
1170
+ {file.name}
1171
+ </button>
1172
+ ))}
1173
+ </div>
1174
+
1175
+ {selectedFile && (
1176
+ <div className="mt-3">
1177
+ <div className="mb-2 flex items-center justify-between gap-2">
1178
+ <div className="text-[12px] font-600 text-text-2">{selectedFile.name}</div>
1179
+ <button
1180
+ type="button"
1181
+ onClick={() => onCopied(`file:${selectedFile.name}`, selectedFile.content)}
1182
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all"
1183
+ >
1184
+ {copiedKey === `file:${selectedFile.name}` ? 'Copied' : 'Copy file'}
1185
+ </button>
1186
+ </div>
1187
+ <pre className="overflow-x-auto rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-3 text-[11px] text-text-2/80 whitespace-pre-wrap">
1188
+ {selectedFile.content}
1189
+ </pre>
1190
+ </div>
1191
+ )}
1192
+ </div>
1193
+ )}
1194
+ </div>
1195
+ )}
1196
+
1197
+ {(message || error || localStatus?.lastError || remoteStatus?.lastError) && (
1198
+ <div className={`mt-4 rounded-[12px] border px-3 py-2 text-[12px] ${
1199
+ error || localStatus?.lastError || remoteStatus?.lastError
1200
+ ? 'border-red-400/20 bg-red-400/[0.06] text-red-200'
1201
+ : 'border-emerald-500/20 bg-emerald-500/[0.06] text-emerald-200'
1202
+ }`}>
1203
+ {error || localStatus?.lastError || remoteStatus?.lastError || message}
1204
+ </div>
1205
+ )}
1206
+ </div>
1207
+ )
1208
+ }