@swarmclawai/swarmclaw 0.7.4 → 0.7.6
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 +32 -9
- package/package.json +2 -2
- package/src/app/api/agents/[id]/thread/route.ts +4 -89
- package/src/app/api/openclaw/deploy/route.ts +101 -0
- package/src/cli/index.js +13 -0
- package/src/cli/index.test.js +34 -0
- package/src/cli/spec.js +19 -0
- package/src/components/auth/setup-wizard.tsx +36 -52
- package/src/components/gateways/gateway-sheet.tsx +63 -3
- package/src/components/openclaw/openclaw-deploy-panel.tsx +626 -0
- package/src/components/providers/provider-list.tsx +103 -8
- package/src/lib/server/agent-thread-session.test.ts +85 -0
- package/src/lib/server/agent-thread-session.ts +123 -0
- package/src/lib/server/data-dir.test.ts +56 -0
- package/src/lib/server/data-dir.ts +15 -9
- package/src/lib/server/heartbeat-service.ts +18 -5
- package/src/lib/server/heartbeat-wake.ts +6 -2
- package/src/lib/server/openclaw-deploy.test.ts +67 -0
- package/src/lib/server/openclaw-deploy.ts +724 -0
|
@@ -0,0 +1,626 @@
|
|
|
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
|
+
|
|
10
|
+
interface LocalDeployStatus {
|
|
11
|
+
running: boolean
|
|
12
|
+
processId: string | null
|
|
13
|
+
pid: number | null
|
|
14
|
+
port: number
|
|
15
|
+
endpoint: string
|
|
16
|
+
wsUrl: string
|
|
17
|
+
token: string | null
|
|
18
|
+
startedAt: number | null
|
|
19
|
+
tail: string
|
|
20
|
+
lastError: string | null
|
|
21
|
+
launchCommand: string
|
|
22
|
+
installCommand: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface DeployFile {
|
|
26
|
+
name: string
|
|
27
|
+
language: 'bash' | 'yaml' | 'env' | 'toml' | 'text'
|
|
28
|
+
content: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DeployBundle {
|
|
32
|
+
template: RemoteTemplate
|
|
33
|
+
provider: RemoteProvider
|
|
34
|
+
providerLabel: string
|
|
35
|
+
title: string
|
|
36
|
+
summary: string
|
|
37
|
+
endpoint: string
|
|
38
|
+
wsUrl: string
|
|
39
|
+
token: string
|
|
40
|
+
runbook: string[]
|
|
41
|
+
files: DeployFile[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DeployStatusResponse {
|
|
45
|
+
local: LocalDeployStatus
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface DeployActionResponse {
|
|
49
|
+
ok: boolean
|
|
50
|
+
local?: LocalDeployStatus
|
|
51
|
+
token?: string
|
|
52
|
+
bundle?: DeployBundle
|
|
53
|
+
error?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ApplyPatch {
|
|
57
|
+
endpoint?: string
|
|
58
|
+
token?: string
|
|
59
|
+
name?: string
|
|
60
|
+
notes?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface OpenClawDeployPanelProps {
|
|
64
|
+
endpoint?: string | null
|
|
65
|
+
token?: string | null
|
|
66
|
+
suggestedName?: string | null
|
|
67
|
+
title?: string
|
|
68
|
+
description?: string
|
|
69
|
+
compact?: boolean
|
|
70
|
+
onApply?: (patch: ApplyPatch) => void | Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const TEMPLATE_OPTIONS: Array<{
|
|
74
|
+
id: RemoteTemplate
|
|
75
|
+
label: string
|
|
76
|
+
detail: string
|
|
77
|
+
}> = [
|
|
78
|
+
{
|
|
79
|
+
id: 'docker',
|
|
80
|
+
label: 'VPS Smart Deploy',
|
|
81
|
+
detail: 'Official OpenClaw Docker image plus cloud-init for mainstream VPS hosts',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'render',
|
|
85
|
+
label: 'Render',
|
|
86
|
+
detail: 'Managed HTTPS with a repo-backed Docker service',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'fly',
|
|
90
|
+
label: 'Fly.io',
|
|
91
|
+
detail: 'Persistent remote gateway with Fly volumes and HTTPS',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'railway',
|
|
95
|
+
label: 'Railway',
|
|
96
|
+
detail: 'Simple Docker deploy with volume-backed state',
|
|
97
|
+
},
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
const PROVIDER_OPTIONS: Array<{
|
|
101
|
+
id: RemoteProvider
|
|
102
|
+
label: string
|
|
103
|
+
detail: string
|
|
104
|
+
}> = [
|
|
105
|
+
{ id: 'hetzner', label: 'Hetzner', detail: 'Cheap always-on VPS' },
|
|
106
|
+
{ id: 'digitalocean', label: 'DigitalOcean', detail: 'Droplet + user-data flow' },
|
|
107
|
+
{ id: 'vultr', label: 'Vultr', detail: 'Cloud Compute startup script' },
|
|
108
|
+
{ id: 'linode', label: 'Linode', detail: 'Simple Ubuntu VM path' },
|
|
109
|
+
{ id: 'lightsail', label: 'Lightsail', detail: 'AWS-hosted simple VPS' },
|
|
110
|
+
{ id: 'gcp', label: 'GCP', detail: 'Compute Engine VM' },
|
|
111
|
+
{ id: 'azure', label: 'Azure', detail: 'Ubuntu VM custom data' },
|
|
112
|
+
{ id: 'oci', label: 'OCI', detail: 'Oracle cloud-init bootstrap' },
|
|
113
|
+
{ id: 'generic', label: 'Generic', detail: 'Any Ubuntu 24.04 host' },
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
function buildLocalRunCommand(port: number, token?: string | null): string {
|
|
117
|
+
const parts = ['npx', 'openclaw', 'gateway', 'run', '--allow-unconfigured', '--force', '--bind', 'loopback', '--port', String(port)]
|
|
118
|
+
if (token) parts.push('--auth', 'token', '--token', token)
|
|
119
|
+
return parts.join(' ')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildLocalInstallCommand(port: number, token?: string | null): string {
|
|
123
|
+
const parts = ['npx', 'openclaw', 'gateway', 'install', '--port', String(port)]
|
|
124
|
+
if (token) parts.push('--token', token)
|
|
125
|
+
return `${parts.join(' ')} && npx openclaw gateway start`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseMaybeUrl(value: string | null | undefined): URL | null {
|
|
129
|
+
const trimmed = typeof value === 'string' ? value.trim() : ''
|
|
130
|
+
if (!trimmed) return null
|
|
131
|
+
try {
|
|
132
|
+
return new URL(trimmed)
|
|
133
|
+
} catch {
|
|
134
|
+
try {
|
|
135
|
+
return new URL(`http://${trimmed}`)
|
|
136
|
+
} catch {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isLocalEndpoint(value: string | null | undefined): boolean {
|
|
143
|
+
const parsed = parseMaybeUrl(value)
|
|
144
|
+
if (!parsed) return false
|
|
145
|
+
const host = parsed.hostname.toLowerCase()
|
|
146
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function inferPort(value: string | null | undefined, fallback = 18789): number {
|
|
150
|
+
const parsed = parseMaybeUrl(value)
|
|
151
|
+
if (!parsed?.port) return fallback
|
|
152
|
+
const port = Number.parseInt(parsed.port, 10)
|
|
153
|
+
return Number.isFinite(port) ? port : fallback
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function inferRemoteTarget(value: string | null | undefined): string {
|
|
157
|
+
const parsed = parseMaybeUrl(value)
|
|
158
|
+
if (!parsed || isLocalEndpoint(value)) return ''
|
|
159
|
+
const base = `${parsed.protocol}//${parsed.host}`
|
|
160
|
+
return base.replace(/\/+$/, '')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function badgeTone(active: boolean): string {
|
|
164
|
+
return active
|
|
165
|
+
? 'border-accent-bright/30 bg-accent-bright/10 text-accent-bright'
|
|
166
|
+
: 'border-white/[0.08] bg-white/[0.02] text-text-2 hover:bg-white/[0.05]'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
|
|
170
|
+
const {
|
|
171
|
+
endpoint,
|
|
172
|
+
token,
|
|
173
|
+
suggestedName,
|
|
174
|
+
title = 'Smart Deploy OpenClaw',
|
|
175
|
+
description = 'Launch a local gateway on this host or generate a remote bundle with opinionated defaults.',
|
|
176
|
+
compact = false,
|
|
177
|
+
onApply,
|
|
178
|
+
} = props
|
|
179
|
+
|
|
180
|
+
const [activeTab, setActiveTab] = useState<'local' | 'remote'>('local')
|
|
181
|
+
const [localStatus, setLocalStatus] = useState<LocalDeployStatus | null>(null)
|
|
182
|
+
const [localPort, setLocalPort] = useState(() => inferPort(endpoint))
|
|
183
|
+
const [deployToken, setDeployToken] = useState(token || '')
|
|
184
|
+
const [remoteTarget, setRemoteTarget] = useState(() => inferRemoteTarget(endpoint))
|
|
185
|
+
const [remoteScheme, setRemoteScheme] = useState<'http' | 'https'>(() => (
|
|
186
|
+
typeof endpoint === 'string' && endpoint.trim().startsWith('http://') ? 'http' : 'https'
|
|
187
|
+
))
|
|
188
|
+
const [remoteTemplate, setRemoteTemplate] = useState<RemoteTemplate>('docker')
|
|
189
|
+
const [remoteProvider, setRemoteProvider] = useState<RemoteProvider>('hetzner')
|
|
190
|
+
const [bundle, setBundle] = useState<DeployBundle | null>(null)
|
|
191
|
+
const [bundleFile, setBundleFile] = useState('')
|
|
192
|
+
const [loading, setLoading] = useState<'idle' | 'starting-local' | 'stopping-local' | 'generating-bundle'>('idle')
|
|
193
|
+
const [message, setMessage] = useState('')
|
|
194
|
+
const [error, setError] = useState('')
|
|
195
|
+
const [copiedKey, setCopiedKey] = useState('')
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (token && !deployToken) setDeployToken(token)
|
|
199
|
+
}, [token, deployToken])
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (endpoint && isLocalEndpoint(endpoint)) {
|
|
203
|
+
setLocalPort(inferPort(endpoint))
|
|
204
|
+
setActiveTab('local')
|
|
205
|
+
} else if (endpoint && inferRemoteTarget(endpoint)) {
|
|
206
|
+
setRemoteTarget(inferRemoteTarget(endpoint))
|
|
207
|
+
setActiveTab('remote')
|
|
208
|
+
}
|
|
209
|
+
}, [endpoint])
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
let cancelled = false
|
|
213
|
+
api<DeployStatusResponse>('GET', '/openclaw/deploy')
|
|
214
|
+
.then((result) => {
|
|
215
|
+
if (!cancelled) {
|
|
216
|
+
setLocalStatus(result.local)
|
|
217
|
+
if (result.local.token) {
|
|
218
|
+
setDeployToken((current) => current || result.local.token || '')
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
.catch(() => {})
|
|
223
|
+
return () => {
|
|
224
|
+
cancelled = true
|
|
225
|
+
}
|
|
226
|
+
}, [])
|
|
227
|
+
|
|
228
|
+
const selectedFile = useMemo(() => {
|
|
229
|
+
if (!bundle) return null
|
|
230
|
+
return bundle.files.find((file) => file.name === bundleFile) || bundle.files[0] || null
|
|
231
|
+
}, [bundle, bundleFile])
|
|
232
|
+
const localLaunchCommand = useMemo(() => {
|
|
233
|
+
const typedToken = deployToken.trim()
|
|
234
|
+
if (typedToken) return buildLocalRunCommand(localPort, typedToken)
|
|
235
|
+
if (localStatus?.launchCommand) return localStatus.launchCommand
|
|
236
|
+
return buildLocalRunCommand(localPort)
|
|
237
|
+
}, [deployToken, localPort, localStatus?.launchCommand])
|
|
238
|
+
const localInstallCommand = useMemo(() => {
|
|
239
|
+
const typedToken = deployToken.trim()
|
|
240
|
+
if (typedToken) return buildLocalInstallCommand(localPort, typedToken)
|
|
241
|
+
if (localStatus?.installCommand) return localStatus.installCommand
|
|
242
|
+
return buildLocalInstallCommand(localPort)
|
|
243
|
+
}, [deployToken, localPort, localStatus?.installCommand])
|
|
244
|
+
|
|
245
|
+
const showMessage = (next: string) => {
|
|
246
|
+
setMessage(next)
|
|
247
|
+
if (!next) return
|
|
248
|
+
window.setTimeout(() => {
|
|
249
|
+
setMessage((current) => (current === next ? '' : current))
|
|
250
|
+
}, 2200)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const onCopied = async (key: string, value: string) => {
|
|
254
|
+
const ok = await copyTextToClipboard(value)
|
|
255
|
+
if (!ok) return
|
|
256
|
+
setCopiedKey(key)
|
|
257
|
+
window.setTimeout(() => {
|
|
258
|
+
setCopiedKey((current) => (current === key ? '' : current))
|
|
259
|
+
}, 1200)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const handleStartLocal = async () => {
|
|
263
|
+
setLoading('starting-local')
|
|
264
|
+
setError('')
|
|
265
|
+
try {
|
|
266
|
+
const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
|
|
267
|
+
action: 'start-local',
|
|
268
|
+
port: localPort,
|
|
269
|
+
token: deployToken.trim() || undefined,
|
|
270
|
+
})
|
|
271
|
+
if (!result.ok || !result.local) throw new Error(result.error || 'Local OpenClaw deploy failed.')
|
|
272
|
+
setLocalStatus(result.local)
|
|
273
|
+
if (result.token) setDeployToken(result.token)
|
|
274
|
+
await Promise.resolve(onApply?.({
|
|
275
|
+
endpoint: result.local.endpoint,
|
|
276
|
+
token: result.token || deployToken,
|
|
277
|
+
name: suggestedName || `Local OpenClaw ${result.local.port}`,
|
|
278
|
+
notes: 'Managed by SwarmClaw local deploy.',
|
|
279
|
+
}))
|
|
280
|
+
showMessage('Local OpenClaw started and applied to this connection.')
|
|
281
|
+
} catch (err: unknown) {
|
|
282
|
+
setError(err instanceof Error ? err.message : 'Local OpenClaw deploy failed.')
|
|
283
|
+
} finally {
|
|
284
|
+
setLoading('idle')
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const handleStopLocal = async () => {
|
|
289
|
+
setLoading('stopping-local')
|
|
290
|
+
setError('')
|
|
291
|
+
try {
|
|
292
|
+
const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', { action: 'stop-local' })
|
|
293
|
+
if (!result.ok || !result.local) throw new Error(result.error || 'Failed to stop local OpenClaw.')
|
|
294
|
+
setLocalStatus(result.local)
|
|
295
|
+
showMessage('Stopped managed local OpenClaw runtime.')
|
|
296
|
+
} catch (err: unknown) {
|
|
297
|
+
setError(err instanceof Error ? err.message : 'Failed to stop local OpenClaw.')
|
|
298
|
+
} finally {
|
|
299
|
+
setLoading('idle')
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const handleGenerateBundle = async () => {
|
|
304
|
+
setLoading('generating-bundle')
|
|
305
|
+
setError('')
|
|
306
|
+
try {
|
|
307
|
+
const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
|
|
308
|
+
action: 'bundle',
|
|
309
|
+
template: remoteTemplate,
|
|
310
|
+
target: remoteTarget.trim(),
|
|
311
|
+
scheme: remoteScheme,
|
|
312
|
+
token: deployToken.trim() || undefined,
|
|
313
|
+
provider: remoteProvider,
|
|
314
|
+
})
|
|
315
|
+
if (!result.ok || !result.bundle) throw new Error(result.error || 'Failed to generate OpenClaw deploy bundle.')
|
|
316
|
+
setBundle(result.bundle)
|
|
317
|
+
setBundleFile(result.bundle.files[0]?.name || '')
|
|
318
|
+
setDeployToken(result.bundle.token)
|
|
319
|
+
await Promise.resolve(onApply?.({
|
|
320
|
+
endpoint: result.bundle.endpoint,
|
|
321
|
+
token: result.bundle.token,
|
|
322
|
+
name: suggestedName || result.bundle.title,
|
|
323
|
+
notes: `OpenClaw remote deploy template: ${result.bundle.title}`,
|
|
324
|
+
}))
|
|
325
|
+
showMessage('Remote bundle generated and applied to this connection.')
|
|
326
|
+
} catch (err: unknown) {
|
|
327
|
+
setError(err instanceof Error ? err.message : 'Failed to generate OpenClaw deploy bundle.')
|
|
328
|
+
} finally {
|
|
329
|
+
setLoading('idle')
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div className={`rounded-[16px] border border-white/[0.08] bg-surface ${compact ? 'p-4' : 'p-5'} text-left`}>
|
|
335
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
336
|
+
<div>
|
|
337
|
+
<div className="font-display text-[16px] font-700 text-text">{title}</div>
|
|
338
|
+
<p className="mt-1 text-[12px] text-text-3 leading-relaxed">{description}</p>
|
|
339
|
+
</div>
|
|
340
|
+
<div className="flex items-center gap-2">
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={() => setActiveTab('local')}
|
|
344
|
+
className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(activeTab === 'local')}`}
|
|
345
|
+
>
|
|
346
|
+
Local
|
|
347
|
+
</button>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
onClick={() => setActiveTab('remote')}
|
|
351
|
+
className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(activeTab === 'remote')}`}
|
|
352
|
+
>
|
|
353
|
+
Remote
|
|
354
|
+
</button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
{activeTab === 'local' && (
|
|
359
|
+
<div className="mt-4 space-y-4">
|
|
360
|
+
<div className="grid gap-3 md:grid-cols-[120px_1fr]">
|
|
361
|
+
<div>
|
|
362
|
+
<label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Port</label>
|
|
363
|
+
<input
|
|
364
|
+
type="number"
|
|
365
|
+
value={localPort}
|
|
366
|
+
onChange={(e) => setLocalPort(Number.parseInt(e.target.value, 10) || 18789)}
|
|
367
|
+
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"
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
<div>
|
|
371
|
+
<label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Gateway token</label>
|
|
372
|
+
<input
|
|
373
|
+
type="text"
|
|
374
|
+
value={deployToken}
|
|
375
|
+
onChange={(e) => setDeployToken(e.target.value)}
|
|
376
|
+
placeholder="Leave blank to generate a secure token"
|
|
377
|
+
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"
|
|
378
|
+
/>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-3">
|
|
383
|
+
<div className="flex items-center justify-between gap-3">
|
|
384
|
+
<div>
|
|
385
|
+
<div className="text-[13px] font-600 text-text">Managed local runtime</div>
|
|
386
|
+
<div className="mt-1 text-[12px] text-text-3">
|
|
387
|
+
One-click bring-up on the same machine running SwarmClaw. Good for quickstarts and non-technical local installs.
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
<div className={`rounded-full px-2.5 py-1 text-[10px] font-700 uppercase tracking-[0.08em] ${
|
|
391
|
+
localStatus?.running
|
|
392
|
+
? 'bg-emerald-500/10 text-emerald-300'
|
|
393
|
+
: 'bg-white/[0.05] text-text-3'
|
|
394
|
+
}`}>
|
|
395
|
+
{localStatus?.running ? 'running' : 'idle'}
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
onClick={handleStartLocal}
|
|
403
|
+
disabled={loading !== 'idle'}
|
|
404
|
+
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"
|
|
405
|
+
>
|
|
406
|
+
{loading === 'starting-local' ? 'Starting…' : 'Deploy on This Host'}
|
|
407
|
+
</button>
|
|
408
|
+
{localStatus?.running && (
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
onClick={handleStopLocal}
|
|
412
|
+
disabled={loading !== 'idle'}
|
|
413
|
+
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"
|
|
414
|
+
>
|
|
415
|
+
{loading === 'stopping-local' ? 'Stopping…' : 'Stop'}
|
|
416
|
+
</button>
|
|
417
|
+
)}
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
onClick={() => onCopied('local-launch', localLaunchCommand)}
|
|
421
|
+
disabled={!localLaunchCommand}
|
|
422
|
+
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"
|
|
423
|
+
>
|
|
424
|
+
{copiedKey === 'local-launch' ? 'Copied launch' : 'Copy launch cmd'}
|
|
425
|
+
</button>
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
onClick={() => onCopied('local-install', localInstallCommand)}
|
|
429
|
+
disabled={!localInstallCommand}
|
|
430
|
+
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"
|
|
431
|
+
>
|
|
432
|
+
{copiedKey === 'local-install' ? 'Copied install' : 'Copy service cmd'}
|
|
433
|
+
</button>
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
onClick={() => onCopied('local-token', deployToken.trim() || localStatus?.token || '')}
|
|
437
|
+
disabled={!deployToken.trim() && !localStatus?.token}
|
|
438
|
+
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"
|
|
439
|
+
>
|
|
440
|
+
{copiedKey === 'local-token' ? 'Copied token' : 'Copy token'}
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
{localStatus && (
|
|
445
|
+
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
|
446
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
447
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Endpoint</div>
|
|
448
|
+
<div className="mt-1 text-[12px] text-text-2 font-mono break-all">{localStatus.endpoint}</div>
|
|
449
|
+
</div>
|
|
450
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
451
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Persistent install</div>
|
|
452
|
+
<div className="mt-1 text-[12px] text-text-3 leading-relaxed">
|
|
453
|
+
For a durable OS service, use the generated install command after the quick deploy works.
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{!!localStatus?.tail && (
|
|
460
|
+
<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">
|
|
461
|
+
{localStatus.tail}
|
|
462
|
+
</pre>
|
|
463
|
+
)}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
|
|
468
|
+
{activeTab === 'remote' && (
|
|
469
|
+
<div className="mt-4 space-y-4">
|
|
470
|
+
<div className="grid gap-3 md:grid-cols-[1fr_120px]">
|
|
471
|
+
<div>
|
|
472
|
+
<label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Public host or URL</label>
|
|
473
|
+
<input
|
|
474
|
+
type="text"
|
|
475
|
+
value={remoteTarget}
|
|
476
|
+
onChange={(e) => setRemoteTarget(e.target.value)}
|
|
477
|
+
placeholder="openclaw.example.com or https://openclaw.example.com"
|
|
478
|
+
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"
|
|
479
|
+
/>
|
|
480
|
+
</div>
|
|
481
|
+
<div>
|
|
482
|
+
<label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Scheme</label>
|
|
483
|
+
<select
|
|
484
|
+
value={remoteScheme}
|
|
485
|
+
onChange={(e) => setRemoteScheme(e.target.value === 'http' ? 'http' : 'https')}
|
|
486
|
+
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"
|
|
487
|
+
>
|
|
488
|
+
<option value="https">https</option>
|
|
489
|
+
<option value="http">http</option>
|
|
490
|
+
</select>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<div>
|
|
495
|
+
<div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Deploy target</div>
|
|
496
|
+
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
|
|
497
|
+
{TEMPLATE_OPTIONS.map((option) => (
|
|
498
|
+
<button
|
|
499
|
+
key={option.id}
|
|
500
|
+
type="button"
|
|
501
|
+
onClick={() => setRemoteTemplate(option.id)}
|
|
502
|
+
className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(remoteTemplate === option.id)}`}
|
|
503
|
+
>
|
|
504
|
+
<div className="text-[13px] font-700">{option.label}</div>
|
|
505
|
+
<div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
|
|
506
|
+
</button>
|
|
507
|
+
))}
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
|
|
511
|
+
{remoteTemplate === 'docker' && (
|
|
512
|
+
<div>
|
|
513
|
+
<div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">VPS provider</div>
|
|
514
|
+
<div className="grid gap-2 md:grid-cols-3 xl:grid-cols-5">
|
|
515
|
+
{PROVIDER_OPTIONS.map((option) => (
|
|
516
|
+
<button
|
|
517
|
+
key={option.id}
|
|
518
|
+
type="button"
|
|
519
|
+
onClick={() => setRemoteProvider(option.id)}
|
|
520
|
+
className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(remoteProvider === option.id)}`}
|
|
521
|
+
>
|
|
522
|
+
<div className="text-[13px] font-700">{option.label}</div>
|
|
523
|
+
<div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
|
|
524
|
+
</button>
|
|
525
|
+
))}
|
|
526
|
+
</div>
|
|
527
|
+
<p className="mt-2 text-[12px] text-text-3 leading-relaxed">
|
|
528
|
+
SwarmClaw generates a provider-specific runbook plus a cloud-init quickstart, but the runtime itself still comes from the official OpenClaw Docker image.
|
|
529
|
+
</p>
|
|
530
|
+
</div>
|
|
531
|
+
)}
|
|
532
|
+
|
|
533
|
+
<div className="flex flex-wrap gap-2">
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
onClick={handleGenerateBundle}
|
|
537
|
+
disabled={loading !== 'idle'}
|
|
538
|
+
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"
|
|
539
|
+
>
|
|
540
|
+
{loading === 'generating-bundle' ? 'Generating…' : 'Generate Bundle'}
|
|
541
|
+
</button>
|
|
542
|
+
{bundle && (
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
onClick={() => onCopied('remote-token', bundle.token)}
|
|
546
|
+
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"
|
|
547
|
+
>
|
|
548
|
+
{copiedKey === 'remote-token' ? 'Copied token' : 'Copy token'}
|
|
549
|
+
</button>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
{bundle && (
|
|
554
|
+
<div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
|
|
555
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
556
|
+
<div>
|
|
557
|
+
<div className="text-[14px] font-700 text-text">{bundle.title}</div>
|
|
558
|
+
<p className="mt-1 text-[12px] text-text-3 leading-relaxed">{bundle.summary}</p>
|
|
559
|
+
</div>
|
|
560
|
+
<div className="grid gap-2 md:grid-cols-2">
|
|
561
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
|
562
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Endpoint</div>
|
|
563
|
+
<div className="mt-1 text-[11px] font-mono text-text-2 break-all">{bundle.endpoint}</div>
|
|
564
|
+
</div>
|
|
565
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
|
566
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Host path</div>
|
|
567
|
+
<div className="mt-1 text-[11px] text-text-2">{bundle.providerLabel}</div>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
<div className="mt-3 grid gap-2">
|
|
573
|
+
{bundle.runbook.map((step, index) => (
|
|
574
|
+
<div key={`${bundle.template}:${index}`} className="text-[12px] text-text-2 leading-relaxed">
|
|
575
|
+
{index + 1}. {step}
|
|
576
|
+
</div>
|
|
577
|
+
))}
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
581
|
+
{bundle.files.map((file) => (
|
|
582
|
+
<button
|
|
583
|
+
key={file.name}
|
|
584
|
+
type="button"
|
|
585
|
+
onClick={() => setBundleFile(file.name)}
|
|
586
|
+
className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(selectedFile?.name === file.name)}`}
|
|
587
|
+
>
|
|
588
|
+
{file.name}
|
|
589
|
+
</button>
|
|
590
|
+
))}
|
|
591
|
+
</div>
|
|
592
|
+
|
|
593
|
+
{selectedFile && (
|
|
594
|
+
<div className="mt-3">
|
|
595
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
596
|
+
<div className="text-[12px] font-600 text-text-2">{selectedFile.name}</div>
|
|
597
|
+
<button
|
|
598
|
+
type="button"
|
|
599
|
+
onClick={() => onCopied(`file:${selectedFile.name}`, selectedFile.content)}
|
|
600
|
+
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"
|
|
601
|
+
>
|
|
602
|
+
{copiedKey === `file:${selectedFile.name}` ? 'Copied' : 'Copy file'}
|
|
603
|
+
</button>
|
|
604
|
+
</div>
|
|
605
|
+
<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">
|
|
606
|
+
{selectedFile.content}
|
|
607
|
+
</pre>
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
</div>
|
|
611
|
+
)}
|
|
612
|
+
</div>
|
|
613
|
+
)}
|
|
614
|
+
|
|
615
|
+
{(message || error || localStatus?.lastError) && (
|
|
616
|
+
<div className={`mt-4 rounded-[12px] border px-3 py-2 text-[12px] ${
|
|
617
|
+
error || localStatus?.lastError
|
|
618
|
+
? 'border-red-400/20 bg-red-400/[0.06] text-red-200'
|
|
619
|
+
: 'border-emerald-500/20 bg-emerald-500/[0.06] text-emerald-200'
|
|
620
|
+
}`}>
|
|
621
|
+
{error || localStatus?.lastError || message}
|
|
622
|
+
</div>
|
|
623
|
+
)}
|
|
624
|
+
</div>
|
|
625
|
+
)
|
|
626
|
+
}
|