@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
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
|
|
4
6
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
7
|
import { useWs } from '@/hooks/use-ws'
|
|
6
8
|
import { api } from '@/lib/api-client'
|
|
9
|
+
import type { Credential, GatewayProfile } from '@/types'
|
|
10
|
+
|
|
11
|
+
interface OpenClawDeployDraft {
|
|
12
|
+
endpoint: string
|
|
13
|
+
token?: string
|
|
14
|
+
name?: string
|
|
15
|
+
notes?: string
|
|
16
|
+
deployment?: GatewayProfile['deployment']
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatRuntimeTimestamp(value: number | null | undefined): string {
|
|
20
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 'Never'
|
|
21
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
22
|
+
month: 'short',
|
|
23
|
+
day: 'numeric',
|
|
24
|
+
hour: 'numeric',
|
|
25
|
+
minute: '2-digit',
|
|
26
|
+
}).format(value)
|
|
27
|
+
}
|
|
7
28
|
|
|
8
29
|
export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
9
30
|
const providers = useAppStore((s) => s.providers)
|
|
@@ -21,6 +42,8 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
21
42
|
const setGatewaySheetOpen = useAppStore((s) => s.setGatewaySheetOpen)
|
|
22
43
|
const setEditingGatewayId = useAppStore((s) => s.setEditingGatewayId)
|
|
23
44
|
const [loaded, setLoaded] = useState(false)
|
|
45
|
+
const [deployDraft, setDeployDraft] = useState<OpenClawDeployDraft | null>(null)
|
|
46
|
+
const [savingDeploy, setSavingDeploy] = useState(false)
|
|
24
47
|
|
|
25
48
|
const refresh = useCallback(async () => {
|
|
26
49
|
await Promise.all([loadProviders(), loadProviderConfigs(), loadGatewayProfiles(), loadExternalAgents(), loadCredentials()])
|
|
@@ -66,6 +89,132 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
66
89
|
await loadGatewayProfiles()
|
|
67
90
|
}
|
|
68
91
|
|
|
92
|
+
const handleDeployApply = (patch: { endpoint?: string; token?: string; name?: string; notes?: string; deployment?: GatewayProfile['deployment'] | Record<string, unknown> | null }) => {
|
|
93
|
+
if (!patch.endpoint) return
|
|
94
|
+
setDeployDraft({
|
|
95
|
+
endpoint: patch.endpoint,
|
|
96
|
+
token: patch.token,
|
|
97
|
+
name: patch.name,
|
|
98
|
+
notes: patch.notes,
|
|
99
|
+
deployment: (patch.deployment as GatewayProfile['deployment']) || null,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleSavePreparedGateway = async () => {
|
|
104
|
+
if (!deployDraft?.endpoint) return
|
|
105
|
+
setSavingDeploy(true)
|
|
106
|
+
try {
|
|
107
|
+
let nextCredentialId: string | null = null
|
|
108
|
+
if (deployDraft.token?.trim()) {
|
|
109
|
+
const credential = await api<Credential>('POST', '/credentials', {
|
|
110
|
+
provider: 'openclaw',
|
|
111
|
+
name: `${deployDraft.name || 'OpenClaw Gateway'} token`,
|
|
112
|
+
apiKey: deployDraft.token.trim(),
|
|
113
|
+
})
|
|
114
|
+
nextCredentialId = credential.id
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const existing = gatewayProfiles.find((gateway) => gateway.endpoint === deployDraft.endpoint) || null
|
|
118
|
+
const nextTags = Array.from(new Set([
|
|
119
|
+
...(existing?.tags || []),
|
|
120
|
+
'managed-deploy',
|
|
121
|
+
...(deployDraft.deployment?.useCase ? [deployDraft.deployment.useCase] : []),
|
|
122
|
+
...(deployDraft.deployment?.exposure ? [deployDraft.deployment.exposure] : []),
|
|
123
|
+
]))
|
|
124
|
+
const verify = await api<{
|
|
125
|
+
ok: boolean
|
|
126
|
+
verify?: {
|
|
127
|
+
ok: boolean
|
|
128
|
+
error?: string
|
|
129
|
+
hint?: string
|
|
130
|
+
models?: string[]
|
|
131
|
+
}
|
|
132
|
+
}>('POST', '/openclaw/deploy', {
|
|
133
|
+
action: 'verify',
|
|
134
|
+
endpoint: deployDraft.endpoint,
|
|
135
|
+
token: deployDraft.token?.trim() || undefined,
|
|
136
|
+
}).catch(() => ({ ok: false, verify: undefined as undefined }))
|
|
137
|
+
const verifiedOk = verify.verify?.ok === true
|
|
138
|
+
const payload = {
|
|
139
|
+
name: deployDraft.name || existing?.name || 'OpenClaw Gateway',
|
|
140
|
+
endpoint: deployDraft.endpoint,
|
|
141
|
+
credentialId: nextCredentialId || existing?.credentialId || null,
|
|
142
|
+
notes: deployDraft.notes || existing?.notes || 'Managed OpenClaw deploy prepared from SwarmClaw.',
|
|
143
|
+
tags: nextTags,
|
|
144
|
+
status: verifiedOk ? 'healthy' : (existing?.status || 'pending'),
|
|
145
|
+
deployment: {
|
|
146
|
+
...(existing?.deployment || {}),
|
|
147
|
+
...(deployDraft.deployment || {}),
|
|
148
|
+
managedBy: 'swarmclaw',
|
|
149
|
+
lastVerifiedAt: verify.verify ? Date.now() : (existing?.deployment?.lastVerifiedAt || null),
|
|
150
|
+
lastVerifiedOk: verify.verify ? verifiedOk : (existing?.deployment?.lastVerifiedOk ?? null),
|
|
151
|
+
lastVerifiedMessage: verify.verify
|
|
152
|
+
? (verifiedOk
|
|
153
|
+
? `Verified during save with ${verify.verify.models?.length || 0} model${(verify.verify.models?.length || 0) === 1 ? '' : 's'}.`
|
|
154
|
+
: (verify.verify.error || verify.verify.hint || 'Verification failed.'))
|
|
155
|
+
: (existing?.deployment?.lastVerifiedMessage || null),
|
|
156
|
+
},
|
|
157
|
+
isDefault: existing?.isDefault === true || gatewayProfiles.length === 0,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (existing) {
|
|
161
|
+
await api('PUT', `/gateways/${existing.id}`, payload)
|
|
162
|
+
} else {
|
|
163
|
+
await api('POST', '/gateways', payload)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await Promise.all([loadGatewayProfiles(), loadCredentials()])
|
|
167
|
+
setDeployDraft(null)
|
|
168
|
+
toast.success(existing ? 'Gateway profile updated' : 'Gateway profile saved')
|
|
169
|
+
} catch (err: unknown) {
|
|
170
|
+
toast.error(err instanceof Error ? err.message : 'Failed to save prepared gateway')
|
|
171
|
+
} finally {
|
|
172
|
+
setSavingDeploy(false)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const handleCloneGateway = async (e: React.MouseEvent, gateway: GatewayProfile) => {
|
|
177
|
+
e.stopPropagation()
|
|
178
|
+
try {
|
|
179
|
+
await api('POST', '/gateways', {
|
|
180
|
+
name: `${gateway.name} Copy`,
|
|
181
|
+
endpoint: gateway.endpoint,
|
|
182
|
+
credentialId: gateway.credentialId || null,
|
|
183
|
+
notes: gateway.notes || null,
|
|
184
|
+
tags: gateway.tags || [],
|
|
185
|
+
deployment: gateway.deployment || null,
|
|
186
|
+
stats: gateway.stats || null,
|
|
187
|
+
isDefault: false,
|
|
188
|
+
})
|
|
189
|
+
await loadGatewayProfiles()
|
|
190
|
+
toast.success('Gateway cloned')
|
|
191
|
+
} catch (err: unknown) {
|
|
192
|
+
toast.error(err instanceof Error ? err.message : 'Failed to clone gateway')
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const handleRuntimeAction = async (
|
|
197
|
+
e: React.MouseEvent,
|
|
198
|
+
runtimeId: string,
|
|
199
|
+
action: 'activate' | 'drain' | 'cordon' | 'restart',
|
|
200
|
+
) => {
|
|
201
|
+
e.stopPropagation()
|
|
202
|
+
try {
|
|
203
|
+
await api('PUT', `/external-agents/${runtimeId}`, { action })
|
|
204
|
+
await loadExternalAgents()
|
|
205
|
+
const actionLabel = action === 'activate'
|
|
206
|
+
? 'Runtime activated'
|
|
207
|
+
: action === 'drain'
|
|
208
|
+
? 'Runtime draining'
|
|
209
|
+
: action === 'cordon'
|
|
210
|
+
? 'Runtime cordoned'
|
|
211
|
+
: 'Restart requested'
|
|
212
|
+
toast.success(actionLabel)
|
|
213
|
+
} catch (err: unknown) {
|
|
214
|
+
toast.error(err instanceof Error ? err.message : 'Runtime action failed')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
69
218
|
// Merge built-in providers with custom configs
|
|
70
219
|
const builtinItems = providers.map((p) => ({
|
|
71
220
|
id: p.id,
|
|
@@ -88,6 +237,18 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
88
237
|
}))
|
|
89
238
|
|
|
90
239
|
const allItems = [...builtinItems, ...customItems]
|
|
240
|
+
const gatewayNameById = new Map(gatewayProfiles.map((gateway) => [gateway.id, gateway.name]))
|
|
241
|
+
const runtimeHealthByGateway = externalAgents.reduce<Record<string, { total: number; active: number; lastHeartbeatAt: number | null }>>((acc, runtime) => {
|
|
242
|
+
if (!runtime.gatewayProfileId) return acc
|
|
243
|
+
const current = acc[runtime.gatewayProfileId] || { total: 0, active: 0, lastHeartbeatAt: null }
|
|
244
|
+
current.total += 1
|
|
245
|
+
if (runtime.status === 'online' || runtime.status === 'idle') current.active += 1
|
|
246
|
+
if (typeof runtime.lastSeenAt === 'number' && (!current.lastHeartbeatAt || runtime.lastSeenAt > current.lastHeartbeatAt)) {
|
|
247
|
+
current.lastHeartbeatAt = runtime.lastSeenAt
|
|
248
|
+
}
|
|
249
|
+
acc[runtime.gatewayProfileId] = current
|
|
250
|
+
return acc
|
|
251
|
+
}, {})
|
|
91
252
|
|
|
92
253
|
if (!loaded) {
|
|
93
254
|
return (
|
|
@@ -180,17 +341,54 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
180
341
|
<div className="mt-8 mb-4 flex items-center justify-between">
|
|
181
342
|
<div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">OpenClaw Gateways</div>
|
|
182
343
|
{!inSidebar && (
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
344
|
+
<div className="flex items-center gap-2">
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={() => handleEditGateway(null)}
|
|
348
|
+
className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] transition-all cursor-pointer"
|
|
349
|
+
>
|
|
350
|
+
+ New Gateway
|
|
351
|
+
</button>
|
|
352
|
+
</div>
|
|
190
353
|
)}
|
|
191
354
|
</div>
|
|
355
|
+
{!inSidebar && (
|
|
356
|
+
<div className="mb-4 rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4">
|
|
357
|
+
<OpenClawDeployPanel
|
|
358
|
+
compact
|
|
359
|
+
title="Deploy OpenClaw Control Planes"
|
|
360
|
+
description="Use official OpenClaw sources only. Start a local control plane on this machine, or generate a pre-configured remote bundle for Docker VPS hosts like Hetzner, DigitalOcean, Vultr, Linode, Lightsail, plus Render, Fly.io, and Railway."
|
|
361
|
+
onApply={handleDeployApply}
|
|
362
|
+
/>
|
|
363
|
+
{deployDraft?.endpoint && (
|
|
364
|
+
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-[12px] border border-emerald-500/20 bg-emerald-500/[0.05] px-4 py-3">
|
|
365
|
+
<div>
|
|
366
|
+
<div className="text-[13px] font-700 text-emerald-300">Prepared gateway profile</div>
|
|
367
|
+
<div className="mt-1 text-[12px] text-text-3">
|
|
368
|
+
{deployDraft.name || 'OpenClaw Gateway'} · <code className="text-text-2">{deployDraft.endpoint}</code>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
<div className="flex flex-wrap gap-2">
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
onClick={() => void handleSavePreparedGateway()}
|
|
375
|
+
disabled={savingDeploy}
|
|
376
|
+
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"
|
|
377
|
+
>
|
|
378
|
+
{savingDeploy ? 'Saving…' : 'Save Prepared Gateway'}
|
|
379
|
+
</button>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
192
385
|
<div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
|
|
193
386
|
{gatewayProfiles.map((gateway, idx) => (
|
|
387
|
+
(() => {
|
|
388
|
+
const runtimeStats = runtimeHealthByGateway[gateway.id] || { total: 0, active: 0, lastHeartbeatAt: null }
|
|
389
|
+
const deployment = gateway.deployment || null
|
|
390
|
+
const stats = gateway.stats || null
|
|
391
|
+
return (
|
|
194
392
|
<div
|
|
195
393
|
key={gateway.id}
|
|
196
394
|
role="button"
|
|
@@ -232,21 +430,61 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
232
430
|
<div className="text-[12px] text-text-3/70">
|
|
233
431
|
{gateway.tags?.length ? gateway.tags.join(', ') : (gateway.notes || 'Dedicated OpenClaw control plane')}
|
|
234
432
|
</div>
|
|
433
|
+
{!inSidebar && (
|
|
434
|
+
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-text-3/65">
|
|
435
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
436
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Deploy</div>
|
|
437
|
+
<div className="mt-1 text-text-2">
|
|
438
|
+
{deployment?.method || 'manual'}
|
|
439
|
+
{deployment?.provider ? ` · ${deployment.provider}` : ''}
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
443
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Route hints</div>
|
|
444
|
+
<div className="mt-1 text-text-2">
|
|
445
|
+
{deployment?.useCase || 'general'}
|
|
446
|
+
{deployment?.exposure ? ` · ${deployment.exposure}` : ''}
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
450
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Nodes / devices</div>
|
|
451
|
+
<div className="mt-1 text-text-2">
|
|
452
|
+
{stats?.connectedNodeCount ?? 0}/{stats?.nodeCount ?? 0} nodes · {stats?.pairedDeviceCount ?? 0} devices
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
456
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Runtimes</div>
|
|
457
|
+
<div className="mt-1 text-text-2">
|
|
458
|
+
{runtimeStats.active}/{runtimeStats.total} active
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
{!inSidebar && deployment?.lastVerifiedMessage && (
|
|
464
|
+
<div className="mt-3 text-[11px] text-text-3/60">
|
|
465
|
+
{deployment.lastVerifiedMessage}
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
235
468
|
{!inSidebar && (
|
|
236
469
|
<div className="mt-3 flex items-center gap-2">
|
|
237
470
|
<button onClick={(e) => void handleHealthCheckGateway(e, gateway.id)} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
|
|
238
471
|
Health
|
|
239
472
|
</button>
|
|
473
|
+
<button onClick={(e) => void handleCloneGateway(e, gateway)} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
|
|
474
|
+
Clone
|
|
475
|
+
</button>
|
|
240
476
|
<button onClick={(e) => handleDeleteGateway(e, gateway.id)} className="px-2.5 py-1.5 rounded-[8px] border border-red-400/20 bg-red-400/[0.06] text-[11px] font-700 text-red-300 hover:bg-red-400/[0.1] cursor-pointer transition-all">
|
|
241
477
|
Delete
|
|
242
478
|
</button>
|
|
243
479
|
</div>
|
|
244
480
|
)}
|
|
245
481
|
</div>
|
|
482
|
+
)
|
|
483
|
+
})()
|
|
246
484
|
))}
|
|
247
485
|
{gatewayProfiles.length === 0 && (
|
|
248
486
|
<div className="p-4 rounded-[14px] border border-dashed border-white/[0.08] text-[13px] text-text-3/70">
|
|
249
|
-
No gateway profiles yet.
|
|
487
|
+
No gateway profiles yet. Use Smart Deploy above for a local runtime, a Docker VPS bundle, or a hosted OpenClaw deployment profile.
|
|
250
488
|
</div>
|
|
251
489
|
)}
|
|
252
490
|
</div>
|
|
@@ -268,23 +506,84 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
268
506
|
<div className="flex items-center justify-between gap-3 mb-2">
|
|
269
507
|
<div className="min-w-0">
|
|
270
508
|
<div className="font-display text-[14px] font-600 text-text truncate">{runtime.name}</div>
|
|
271
|
-
<div className="text-[11px] text-text-3/60 truncate">
|
|
509
|
+
<div className="text-[11px] text-text-3/60 truncate">
|
|
510
|
+
{runtime.sourceType} · {runtime.transport || 'custom'}
|
|
511
|
+
{runtime.version ? ` · ${runtime.version}` : ''}
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
515
|
+
<span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${
|
|
516
|
+
runtime.lifecycleState === 'cordoned'
|
|
517
|
+
? 'bg-red-400/10 text-red-300'
|
|
518
|
+
: runtime.lifecycleState === 'draining'
|
|
519
|
+
? 'bg-amber-400/10 text-amber-300'
|
|
520
|
+
: 'bg-blue-400/10 text-blue-300'
|
|
521
|
+
}`}>
|
|
522
|
+
{runtime.lifecycleState || 'active'}
|
|
523
|
+
</span>
|
|
524
|
+
<span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${
|
|
525
|
+
runtime.status === 'online'
|
|
526
|
+
? 'bg-emerald-400/10 text-emerald-300'
|
|
527
|
+
: runtime.status === 'stale'
|
|
528
|
+
? 'bg-amber-400/10 text-amber-300'
|
|
529
|
+
: 'bg-white/[0.04] text-text-3'
|
|
530
|
+
}`}>
|
|
531
|
+
{runtime.status}
|
|
532
|
+
</span>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
<div className="grid grid-cols-2 gap-2 text-[11px] text-text-3/65">
|
|
536
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
537
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Provider</div>
|
|
538
|
+
<div className="mt-1 text-text-2">
|
|
539
|
+
{runtime.provider || 'No provider'}
|
|
540
|
+
{runtime.model ? ` · ${runtime.model}` : ''}
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
544
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Gateway</div>
|
|
545
|
+
<div className="mt-1 text-text-2">
|
|
546
|
+
{runtime.gatewayProfileId ? (gatewayNameById.get(runtime.gatewayProfileId) || runtime.gatewayProfileId) : 'Standalone'}
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
550
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Template</div>
|
|
551
|
+
<div className="mt-1 text-text-2">{runtime.gatewayUseCase || 'general'}</div>
|
|
552
|
+
</div>
|
|
553
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
554
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Last seen</div>
|
|
555
|
+
<div className="mt-1 text-text-2">{formatRuntimeTimestamp(runtime.lastSeenAt || runtime.lastHeartbeatAt)}</div>
|
|
272
556
|
</div>
|
|
273
|
-
<span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${
|
|
274
|
-
runtime.status === 'online'
|
|
275
|
-
? 'bg-emerald-400/10 text-emerald-300'
|
|
276
|
-
: runtime.status === 'stale'
|
|
277
|
-
? 'bg-amber-400/10 text-amber-300'
|
|
278
|
-
: 'bg-white/[0.04] text-text-3'
|
|
279
|
-
}`}>
|
|
280
|
-
{runtime.status}
|
|
281
|
-
</span>
|
|
282
557
|
</div>
|
|
283
|
-
<div className="text-[
|
|
284
|
-
|
|
285
|
-
|
|
558
|
+
<div className="text-[11px] text-text-3/55 mt-3 font-mono truncate">{runtime.endpoint || runtime.workspace || runtime.id}</div>
|
|
559
|
+
{runtime.gatewayTags?.length ? (
|
|
560
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
561
|
+
{runtime.gatewayTags.slice(0, 6).map((tag) => (
|
|
562
|
+
<span key={`${runtime.id}-${tag}`} className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] text-text-3/70">
|
|
563
|
+
{tag}
|
|
564
|
+
</span>
|
|
565
|
+
))}
|
|
566
|
+
</div>
|
|
567
|
+
) : null}
|
|
568
|
+
{runtime.lastHealthNote && (
|
|
569
|
+
<div className="mt-3 text-[11px] text-text-3/65 leading-relaxed">
|
|
570
|
+
{runtime.lastHealthNote}
|
|
571
|
+
</div>
|
|
572
|
+
)}
|
|
573
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
574
|
+
<button onClick={(e) => void handleRuntimeAction(e, runtime.id, 'activate')} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
|
|
575
|
+
Activate
|
|
576
|
+
</button>
|
|
577
|
+
<button onClick={(e) => void handleRuntimeAction(e, runtime.id, 'drain')} className="px-2.5 py-1.5 rounded-[8px] border border-amber-400/20 bg-amber-400/[0.06] text-[11px] font-700 text-amber-300 hover:bg-amber-400/[0.1] cursor-pointer transition-all">
|
|
578
|
+
Drain
|
|
579
|
+
</button>
|
|
580
|
+
<button onClick={(e) => void handleRuntimeAction(e, runtime.id, 'cordon')} className="px-2.5 py-1.5 rounded-[8px] border border-red-400/20 bg-red-400/[0.06] text-[11px] font-700 text-red-300 hover:bg-red-400/[0.1] cursor-pointer transition-all">
|
|
581
|
+
Cordon
|
|
582
|
+
</button>
|
|
583
|
+
<button onClick={(e) => void handleRuntimeAction(e, runtime.id, 'restart')} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
|
|
584
|
+
Restart
|
|
585
|
+
</button>
|
|
286
586
|
</div>
|
|
287
|
-
<div className="text-[11px] text-text-3/55 mt-2 font-mono truncate">{runtime.endpoint || runtime.workspace || runtime.id}</div>
|
|
288
587
|
</div>
|
|
289
588
|
))}
|
|
290
589
|
{externalAgents.length === 0 && (
|
|
@@ -26,6 +26,11 @@ export interface ResolvedAgentRoute {
|
|
|
26
26
|
source: 'agent' | 'routing-target'
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
interface GatewayRoutePreferences {
|
|
30
|
+
preferredGatewayTags?: string[]
|
|
31
|
+
preferredGatewayUseCase?: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
interface RouteSeed {
|
|
30
35
|
id: string
|
|
31
36
|
label?: string
|
|
@@ -35,6 +40,8 @@ interface RouteSeed {
|
|
|
35
40
|
fallbackCredentialIds?: string[]
|
|
36
41
|
apiEndpoint?: string | null
|
|
37
42
|
gatewayProfileId?: string | null
|
|
43
|
+
preferredGatewayTags?: string[]
|
|
44
|
+
preferredGatewayUseCase?: string | null
|
|
38
45
|
role?: AgentRoutingTarget['role']
|
|
39
46
|
priority?: number
|
|
40
47
|
source: ResolvedAgentRoute['source']
|
|
@@ -47,6 +54,68 @@ function ensureStringArray(value: unknown): string[] {
|
|
|
47
54
|
.filter(Boolean)
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
function normalizeText(value: unknown): string | null {
|
|
58
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeNullableNumber(value: unknown): number | null {
|
|
62
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeGatewayDeployment(
|
|
66
|
+
value: unknown,
|
|
67
|
+
): GatewayProfile['deployment'] {
|
|
68
|
+
if (!value || typeof value !== 'object') return null
|
|
69
|
+
const deployment = value as Record<string, unknown>
|
|
70
|
+
type DeploymentConfig = NonNullable<GatewayProfile['deployment']>
|
|
71
|
+
return {
|
|
72
|
+
method: normalizeText(deployment.method) as DeploymentConfig['method'],
|
|
73
|
+
provider: normalizeText(deployment.provider) as DeploymentConfig['provider'],
|
|
74
|
+
remoteTarget: normalizeText(deployment.remoteTarget) as DeploymentConfig['remoteTarget'],
|
|
75
|
+
useCase: normalizeText(deployment.useCase) as DeploymentConfig['useCase'],
|
|
76
|
+
exposure: normalizeText(deployment.exposure) as DeploymentConfig['exposure'],
|
|
77
|
+
managedBy: normalizeText(deployment.managedBy) as DeploymentConfig['managedBy'],
|
|
78
|
+
targetHost: normalizeText(deployment.targetHost),
|
|
79
|
+
sshHost: normalizeText(deployment.sshHost),
|
|
80
|
+
sshUser: normalizeText(deployment.sshUser),
|
|
81
|
+
sshPort: normalizeNullableNumber(deployment.sshPort),
|
|
82
|
+
sshKeyPath: normalizeText(deployment.sshKeyPath),
|
|
83
|
+
sshTargetDir: normalizeText(deployment.sshTargetDir),
|
|
84
|
+
image: normalizeText(deployment.image),
|
|
85
|
+
version: normalizeText(deployment.version),
|
|
86
|
+
lastDeployAt: normalizeNullableNumber(deployment.lastDeployAt),
|
|
87
|
+
lastDeployAction: normalizeText(deployment.lastDeployAction),
|
|
88
|
+
lastDeployProcessId: normalizeText(deployment.lastDeployProcessId),
|
|
89
|
+
lastDeploySummary: normalizeText(deployment.lastDeploySummary),
|
|
90
|
+
lastVerifiedAt: normalizeNullableNumber(deployment.lastVerifiedAt),
|
|
91
|
+
lastVerifiedOk: typeof deployment.lastVerifiedOk === 'boolean' ? deployment.lastVerifiedOk : null,
|
|
92
|
+
lastVerifiedMessage: normalizeText(deployment.lastVerifiedMessage),
|
|
93
|
+
lastBackupPath: normalizeText(deployment.lastBackupPath),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeGatewayStats(value: unknown): GatewayProfile['stats'] {
|
|
98
|
+
if (!value || typeof value !== 'object') return null
|
|
99
|
+
const stats = value as Record<string, unknown>
|
|
100
|
+
return {
|
|
101
|
+
nodeCount: normalizeNullableNumber(stats.nodeCount) ?? undefined,
|
|
102
|
+
connectedNodeCount: normalizeNullableNumber(stats.connectedNodeCount) ?? undefined,
|
|
103
|
+
pendingNodePairings: normalizeNullableNumber(stats.pendingNodePairings) ?? undefined,
|
|
104
|
+
pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
|
|
105
|
+
pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
|
|
106
|
+
externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeRoutePreferences(
|
|
111
|
+
value?: GatewayRoutePreferences | null,
|
|
112
|
+
): GatewayRoutePreferences {
|
|
113
|
+
return {
|
|
114
|
+
preferredGatewayTags: ensureStringArray(value?.preferredGatewayTags),
|
|
115
|
+
preferredGatewayUseCase: normalizeText(value?.preferredGatewayUseCase),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
50
119
|
function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
|
|
51
120
|
if (!raw || typeof raw !== 'object') return null
|
|
52
121
|
const gateway = raw as Partial<GatewayProfile> & Record<string, unknown>
|
|
@@ -72,6 +141,8 @@ function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
|
|
|
72
141
|
lastModelCount: typeof gateway.lastModelCount === 'number' ? gateway.lastModelCount : null,
|
|
73
142
|
discoveredHost: typeof gateway.discoveredHost === 'string' ? gateway.discoveredHost : null,
|
|
74
143
|
discoveredPort: typeof gateway.discoveredPort === 'number' ? gateway.discoveredPort : null,
|
|
144
|
+
deployment: normalizeGatewayDeployment(gateway.deployment),
|
|
145
|
+
stats: normalizeGatewayStats(gateway.stats),
|
|
75
146
|
isDefault: gateway.isDefault === true,
|
|
76
147
|
createdAt: typeof gateway.createdAt === 'number' ? gateway.createdAt : Date.now(),
|
|
77
148
|
updatedAt: typeof gateway.updatedAt === 'number' ? gateway.updatedAt : Date.now(),
|
|
@@ -107,6 +178,50 @@ function defaultGatewayProfile(gatewayProfiles: GatewayProfile[]): GatewayProfil
|
|
|
107
178
|
return gatewayProfiles.find((profile) => profile.isDefault) || gatewayProfiles[0] || null
|
|
108
179
|
}
|
|
109
180
|
|
|
181
|
+
function gatewayPreferenceScore(
|
|
182
|
+
gatewayProfile: GatewayProfile,
|
|
183
|
+
preferences?: GatewayRoutePreferences | null,
|
|
184
|
+
): number {
|
|
185
|
+
const normalized = normalizeRoutePreferences(preferences)
|
|
186
|
+
const preferredTags = normalized.preferredGatewayTags || []
|
|
187
|
+
const preferredUseCase = normalized.preferredGatewayUseCase || null
|
|
188
|
+
const gatewayTags = new Set(ensureStringArray(gatewayProfile.tags))
|
|
189
|
+
const gatewayUseCase = normalizeText(gatewayProfile.deployment?.useCase)
|
|
190
|
+
|
|
191
|
+
let score = 0
|
|
192
|
+
if (preferredUseCase) {
|
|
193
|
+
if (gatewayUseCase !== preferredUseCase) return -1
|
|
194
|
+
score += 30
|
|
195
|
+
}
|
|
196
|
+
if (preferredTags.length > 0) {
|
|
197
|
+
const matchedTagCount = preferredTags.filter((tag) => gatewayTags.has(tag)).length
|
|
198
|
+
if (matchedTagCount === 0) return -1
|
|
199
|
+
score += matchedTagCount * 10
|
|
200
|
+
if (matchedTagCount === preferredTags.length) score += 8
|
|
201
|
+
}
|
|
202
|
+
if (gatewayProfile.status === 'healthy') score += 4
|
|
203
|
+
else if (gatewayProfile.status === 'degraded') score += 2
|
|
204
|
+
if (gatewayProfile.isDefault) score += 3
|
|
205
|
+
return score
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function pickPreferredGatewayProfile(
|
|
209
|
+
gatewayProfiles: GatewayProfile[],
|
|
210
|
+
preferences?: GatewayRoutePreferences | null,
|
|
211
|
+
): GatewayProfile | null {
|
|
212
|
+
const normalized = normalizeRoutePreferences(preferences)
|
|
213
|
+
if (!(normalized.preferredGatewayTags?.length || normalized.preferredGatewayUseCase)) {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
return gatewayProfiles
|
|
217
|
+
.map((profile) => ({ profile, score: gatewayPreferenceScore(profile, normalized) }))
|
|
218
|
+
.filter((entry) => entry.score >= 0)
|
|
219
|
+
.sort((left, right) => {
|
|
220
|
+
if (left.score !== right.score) return right.score - left.score
|
|
221
|
+
return left.profile.name.localeCompare(right.profile.name)
|
|
222
|
+
})[0]?.profile || null
|
|
223
|
+
}
|
|
224
|
+
|
|
110
225
|
function roleWeight(strategy: AgentRoutingStrategy, role?: AgentRoutingTarget['role']): number {
|
|
111
226
|
const normalized = role || 'primary'
|
|
112
227
|
const matrix: Record<AgentRoutingStrategy, Record<string, number>> = {
|
|
@@ -135,14 +250,21 @@ function dedupeCredentialIds(primary: string | null | undefined, candidates: str
|
|
|
135
250
|
function buildRouteFromSeed(
|
|
136
251
|
seed: RouteSeed,
|
|
137
252
|
gatewayProfiles: GatewayProfile[],
|
|
253
|
+
routePreferences?: GatewayRoutePreferences | null,
|
|
138
254
|
agentGatewayProfileId?: string | null,
|
|
139
255
|
): ResolvedAgentRoute | null {
|
|
140
256
|
const provider = (seed.provider || 'claude-cli') as ProviderType
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
257
|
+
const mergedPreferences = normalizeRoutePreferences({
|
|
258
|
+
preferredGatewayTags: seed.preferredGatewayTags ?? routePreferences?.preferredGatewayTags,
|
|
259
|
+
preferredGatewayUseCase: seed.preferredGatewayUseCase ?? routePreferences?.preferredGatewayUseCase,
|
|
260
|
+
})
|
|
261
|
+
let gatewayProfile = findGatewayProfile(gatewayProfiles, seed.gatewayProfileId ?? null)
|
|
262
|
+
if (!gatewayProfile && provider === 'openclaw') {
|
|
263
|
+
gatewayProfile = pickPreferredGatewayProfile(gatewayProfiles, mergedPreferences)
|
|
264
|
+
|| findGatewayProfile(gatewayProfiles, agentGatewayProfileId ?? null)
|
|
265
|
+
|| defaultGatewayProfile(gatewayProfiles)
|
|
144
266
|
}
|
|
145
|
-
const
|
|
267
|
+
const gatewayProfileId = gatewayProfile?.id ?? seed.gatewayProfileId ?? agentGatewayProfileId ?? null
|
|
146
268
|
|
|
147
269
|
const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
|
|
148
270
|
const apiEndpoint = normalizeProviderEndpoint(
|
|
@@ -189,8 +311,9 @@ function dedupeRoutes(routes: ResolvedAgentRoute[]): ResolvedAgentRoute[] {
|
|
|
189
311
|
export function resolveAgentRouteCandidates(
|
|
190
312
|
agent: Agent | null | undefined,
|
|
191
313
|
preferredStrategy?: AgentRoutingStrategy | null,
|
|
314
|
+
routePreferences?: GatewayRoutePreferences | null,
|
|
192
315
|
): ResolvedAgentRoute[] {
|
|
193
|
-
return resolveAgentRouteCandidatesWithProfiles(agent, getGatewayProfiles('openclaw'), preferredStrategy)
|
|
316
|
+
return resolveAgentRouteCandidatesWithProfiles(agent, getGatewayProfiles('openclaw'), preferredStrategy, undefined, routePreferences)
|
|
194
317
|
}
|
|
195
318
|
|
|
196
319
|
export function resolveAgentRouteCandidatesWithProfiles(
|
|
@@ -198,9 +321,16 @@ export function resolveAgentRouteCandidatesWithProfiles(
|
|
|
198
321
|
gatewayProfiles: GatewayProfile[],
|
|
199
322
|
preferredStrategy?: AgentRoutingStrategy | null,
|
|
200
323
|
isCoolingDown: (providerId: string) => boolean = isProviderCoolingDown,
|
|
324
|
+
routePreferences?: GatewayRoutePreferences | null,
|
|
201
325
|
): ResolvedAgentRoute[] {
|
|
202
326
|
if (!agent) return []
|
|
203
327
|
const strategy = preferredStrategy || agent.routingStrategy || 'single'
|
|
328
|
+
const resolvedPreferences = normalizeRoutePreferences({
|
|
329
|
+
preferredGatewayTags: routePreferences?.preferredGatewayTags?.length
|
|
330
|
+
? routePreferences.preferredGatewayTags
|
|
331
|
+
: agent.preferredGatewayTags,
|
|
332
|
+
preferredGatewayUseCase: routePreferences?.preferredGatewayUseCase || agent.preferredGatewayUseCase,
|
|
333
|
+
})
|
|
204
334
|
const seeds: RouteSeed[] = [
|
|
205
335
|
{
|
|
206
336
|
id: 'base',
|
|
@@ -211,6 +341,8 @@ export function resolveAgentRouteCandidatesWithProfiles(
|
|
|
211
341
|
fallbackCredentialIds: agent.fallbackCredentialIds || [],
|
|
212
342
|
apiEndpoint: agent.apiEndpoint ?? null,
|
|
213
343
|
gatewayProfileId: agent.gatewayProfileId ?? null,
|
|
344
|
+
preferredGatewayTags: agent.preferredGatewayTags || [],
|
|
345
|
+
preferredGatewayUseCase: agent.preferredGatewayUseCase ?? null,
|
|
214
346
|
role: 'primary',
|
|
215
347
|
priority: 0,
|
|
216
348
|
source: 'agent',
|
|
@@ -224,6 +356,8 @@ export function resolveAgentRouteCandidatesWithProfiles(
|
|
|
224
356
|
fallbackCredentialIds: target.fallbackCredentialIds || [],
|
|
225
357
|
apiEndpoint: target.apiEndpoint ?? null,
|
|
226
358
|
gatewayProfileId: target.gatewayProfileId ?? null,
|
|
359
|
+
preferredGatewayTags: target.preferredGatewayTags || [],
|
|
360
|
+
preferredGatewayUseCase: target.preferredGatewayUseCase ?? null,
|
|
227
361
|
role: target.role,
|
|
228
362
|
priority: typeof target.priority === 'number' ? target.priority : index + 1,
|
|
229
363
|
source: 'routing-target' as const,
|
|
@@ -232,7 +366,7 @@ export function resolveAgentRouteCandidatesWithProfiles(
|
|
|
232
366
|
|
|
233
367
|
return dedupeRoutes(
|
|
234
368
|
seeds
|
|
235
|
-
.map((seed) => buildRouteFromSeed(seed, gatewayProfiles, agent.gatewayProfileId ?? null))
|
|
369
|
+
.map((seed) => buildRouteFromSeed(seed, gatewayProfiles, resolvedPreferences, agent.gatewayProfileId ?? null))
|
|
236
370
|
.filter((route): route is ResolvedAgentRoute => Boolean(route)),
|
|
237
371
|
).sort((left, right) => {
|
|
238
372
|
const leftCooling = isCoolingDown(left.provider)
|
|
@@ -249,8 +383,9 @@ export function resolveAgentRouteCandidatesWithProfiles(
|
|
|
249
383
|
export function resolvePrimaryAgentRoute(
|
|
250
384
|
agent: Agent | null | undefined,
|
|
251
385
|
preferredStrategy?: AgentRoutingStrategy | null,
|
|
386
|
+
routePreferences?: GatewayRoutePreferences | null,
|
|
252
387
|
): ResolvedAgentRoute | null {
|
|
253
|
-
return resolveAgentRouteCandidates(agent, preferredStrategy)[0] || null
|
|
388
|
+
return resolveAgentRouteCandidates(agent, preferredStrategy, routePreferences)[0] || null
|
|
254
389
|
}
|
|
255
390
|
|
|
256
391
|
export function applyResolvedRoute<T extends {
|