@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.
- package/README.md +41 -10
- package/package.json +2 -2
- package/src/app/api/agents/[id]/route.ts +16 -0
- package/src/app/api/agents/route.ts +2 -0
- package/src/app/api/chats/[id]/route.ts +21 -1
- package/src/app/api/chats/route.ts +12 -1
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
- package/src/app/api/external-agents/[id]/route.ts +38 -6
- package/src/app/api/external-agents/route.ts +17 -1
- package/src/app/api/gateways/[id]/health/route.ts +8 -0
- package/src/app/api/gateways/[id]/route.ts +53 -1
- package/src/app/api/gateways/route.ts +53 -0
- package/src/app/api/openclaw/deploy/route.ts +240 -0
- package/src/cli/index.js +53 -0
- package/src/cli/index.test.js +102 -0
- package/src/cli/spec.js +79 -0
- package/src/components/agents/agent-sheet.tsx +97 -19
- package/src/components/auth/setup-wizard.tsx +111 -54
- package/src/components/gateways/gateway-sheet.tsx +202 -10
- package/src/components/openclaw/openclaw-deploy-panel.tsx +1208 -0
- package/src/components/providers/provider-list.tsx +321 -22
- package/src/lib/server/agent-runtime-config.ts +142 -7
- package/src/lib/server/agent-thread-session.ts +9 -1
- package/src/lib/server/chat-execution.ts +8 -2
- package/src/lib/server/heartbeat-service.ts +5 -1
- package/src/lib/server/openclaw-deploy.test.ts +75 -0
- package/src/lib/server/openclaw-deploy.ts +1384 -0
- package/src/lib/server/orchestrator.ts +9 -0
- package/src/lib/server/queue.ts +45 -2
- package/src/lib/setup-defaults.ts +2 -2
- package/src/lib/validation/schemas.ts +9 -0
- 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
|
+
}
|