provider-list.tsx
1 'use client' 2 3 import { useState } from 'react' 4 import { toast } from 'sonner' 5 import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel' 6 import { useAppStore } from '@/stores/use-app-store' 7 import { 8 useProviderConfigsQuery, 9 useProvidersQuery, 10 useToggleProviderMutation, 11 useDeleteProviderMutation, 12 } from '@/features/providers/queries' 13 import { useCredentialsQuery, useCreateCredentialMutation } from '@/features/credentials/queries' 14 import { 15 useCloneGatewayProfileMutation, 16 useDeleteGatewayProfileMutation, 17 useGatewayHealthCheckMutation, 18 useGatewayProfilesQuery, 19 useSaveGatewayProfileMutation, 20 useVerifyOpenClawDeployMutation, 21 } from '@/features/gateways/queries' 22 import { useExternalAgentsQuery, useExternalAgentRuntimeMutation } from '@/features/external-agents/queries' 23 import type { GatewayProfile } from '@/types' 24 import { dedup } from '@/lib/shared-utils' 25 import { PageLoader } from '@/components/ui/page-loader' 26 import { StatusDot } from '@/components/ui/status-dot' 27 28 interface OpenClawDeployDraft { 29 endpoint: string 30 token?: string 31 name?: string 32 notes?: string 33 deployment?: GatewayProfile['deployment'] 34 } 35 36 function formatRuntimeTimestamp(value: number | null | undefined): string { 37 if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 'Never' 38 return new Intl.DateTimeFormat(undefined, { 39 month: 'short', 40 day: 'numeric', 41 hour: 'numeric', 42 minute: '2-digit', 43 }).format(value) 44 } 45 46 export function ProviderList({ inSidebar }: { inSidebar?: boolean }) { 47 const setProviderSheetOpen = useAppStore((s) => s.setProviderSheetOpen) 48 const setEditingProviderId = useAppStore((s) => s.setEditingProviderId) 49 const setGatewaySheetOpen = useAppStore((s) => s.setGatewaySheetOpen) 50 const setEditingGatewayId = useAppStore((s) => s.setEditingGatewayId) 51 const [deployDraft, setDeployDraft] = useState<OpenClawDeployDraft | null>(null) 52 const providersQuery = useProvidersQuery() 53 const providerConfigsQuery = useProviderConfigsQuery() 54 const gatewayProfilesQuery = useGatewayProfilesQuery() 55 const externalAgentsQuery = useExternalAgentsQuery() 56 const credentialsQuery = useCredentialsQuery() 57 const toggleProviderMutation = useToggleProviderMutation() 58 const deleteProviderMutation = useDeleteProviderMutation() 59 const createCredentialMutation = useCreateCredentialMutation() 60 const saveGatewayMutation = useSaveGatewayProfileMutation() 61 const deleteGatewayMutation = useDeleteGatewayProfileMutation() 62 const healthCheckGatewayMutation = useGatewayHealthCheckMutation() 63 const verifyDeployMutation = useVerifyOpenClawDeployMutation() 64 const cloneGatewayMutation = useCloneGatewayProfileMutation() 65 const runtimeActionMutation = useExternalAgentRuntimeMutation() 66 67 const providers = providersQuery.data ?? [] 68 const providerConfigs = providerConfigsQuery.data ?? [] 69 const gatewayProfiles = gatewayProfilesQuery.data ?? [] 70 const externalAgents = externalAgentsQuery.data ?? [] 71 const credentials = credentialsQuery.data ?? {} 72 const savingDeploy = createCredentialMutation.isPending 73 || verifyDeployMutation.isPending 74 || saveGatewayMutation.isPending 75 76 const handleEdit = (id: string) => { 77 setEditingProviderId(id) 78 setProviderSheetOpen(true) 79 } 80 81 const handleToggle = async (e: React.MouseEvent, id: string, currentEnabled: boolean) => { 82 e.stopPropagation() 83 await toggleProviderMutation.mutateAsync({ id, isEnabled: !currentEnabled }) 84 } 85 86 const handleDelete = async (e: React.MouseEvent, id: string) => { 87 e.stopPropagation() 88 await deleteProviderMutation.mutateAsync(id) 89 } 90 91 const handleEditGateway = (id: string | null) => { 92 setEditingGatewayId(id) 93 setGatewaySheetOpen(true) 94 } 95 96 const handleDeleteGateway = async (e: React.MouseEvent, id: string) => { 97 e.stopPropagation() 98 await deleteGatewayMutation.mutateAsync(id) 99 } 100 101 const handleHealthCheckGateway = async (e: React.MouseEvent, id: string) => { 102 e.stopPropagation() 103 await healthCheckGatewayMutation.mutateAsync(id) 104 } 105 106 const handleDeployApply = (patch: { endpoint?: string; token?: string; name?: string; notes?: string; deployment?: GatewayProfile['deployment'] | Record<string, unknown> | null }) => { 107 if (!patch.endpoint) return 108 setDeployDraft({ 109 endpoint: patch.endpoint, 110 token: patch.token, 111 name: patch.name, 112 notes: patch.notes, 113 deployment: (patch.deployment as GatewayProfile['deployment']) || null, 114 }) 115 } 116 117 const handleSavePreparedGateway = async () => { 118 if (!deployDraft?.endpoint) return 119 try { 120 let nextCredentialId: string | null = null 121 if (deployDraft.token?.trim()) { 122 const credential = await createCredentialMutation.mutateAsync({ 123 provider: 'openclaw', 124 name: `${deployDraft.name || 'OpenClaw Gateway'} token`, 125 apiKey: deployDraft.token.trim(), 126 }) 127 nextCredentialId = credential.id 128 } 129 130 const existing = gatewayProfiles.find((gateway) => gateway.endpoint === deployDraft.endpoint) || null 131 const nextTags = dedup([ 132 ...(existing?.tags || []), 133 'managed-deploy', 134 ...(deployDraft.deployment?.useCase ? [deployDraft.deployment.useCase] : []), 135 ...(deployDraft.deployment?.exposure ? [deployDraft.deployment.exposure] : []), 136 ]) 137 const verify = await verifyDeployMutation.mutateAsync({ 138 endpoint: deployDraft.endpoint, 139 token: deployDraft.token?.trim() || undefined, 140 }).catch(() => ({ ok: false, verify: undefined as undefined })) 141 const verifiedOk = verify.verify?.ok === true 142 const payload = { 143 name: deployDraft.name || existing?.name || 'OpenClaw Gateway', 144 endpoint: deployDraft.endpoint, 145 credentialId: nextCredentialId || existing?.credentialId || null, 146 notes: deployDraft.notes || existing?.notes || 'Managed OpenClaw deploy prepared from SwarmClaw.', 147 tags: nextTags, 148 status: verifiedOk ? 'healthy' : (existing?.status || 'pending'), 149 deployment: { 150 ...(existing?.deployment || {}), 151 ...(deployDraft.deployment || {}), 152 managedBy: 'swarmclaw', 153 lastVerifiedAt: verify.verify ? +new Date() : (existing?.deployment?.lastVerifiedAt || null), 154 lastVerifiedOk: verify.verify ? verifiedOk : (existing?.deployment?.lastVerifiedOk ?? null), 155 lastVerifiedMessage: verify.verify 156 ? (verifiedOk 157 ? (verify.verify.message || `Verified during save with ${verify.verify.models?.length || 0} model${(verify.verify.models?.length || 0) === 1 ? '' : 's'}.`) 158 : (verify.verify.error || verify.verify.hint || 'Verification failed.')) 159 : (existing?.deployment?.lastVerifiedMessage || null), 160 }, 161 isDefault: existing?.isDefault === true || gatewayProfiles.length === 0, 162 } 163 164 await saveGatewayMutation.mutateAsync({ 165 id: existing?.id, 166 payload, 167 }) 168 setDeployDraft(null) 169 toast.success(existing ? 'Gateway profile updated' : 'Gateway profile saved') 170 } catch (err: unknown) { 171 toast.error(err instanceof Error ? err.message : 'Failed to save prepared gateway') 172 } 173 } 174 175 const handleCloneGateway = async (e: React.MouseEvent, gateway: GatewayProfile) => { 176 e.stopPropagation() 177 try { 178 await cloneGatewayMutation.mutateAsync({ 179 name: `${gateway.name} Copy`, 180 endpoint: gateway.endpoint, 181 credentialId: gateway.credentialId || null, 182 notes: gateway.notes || null, 183 tags: gateway.tags || [], 184 deployment: gateway.deployment || null, 185 stats: gateway.stats || null, 186 isDefault: false, 187 }) 188 toast.success('Gateway cloned') 189 } catch (err: unknown) { 190 toast.error(err instanceof Error ? err.message : 'Failed to clone gateway') 191 } 192 } 193 194 const handleRuntimeAction = async ( 195 e: React.MouseEvent, 196 runtimeId: string, 197 action: 'activate' | 'drain' | 'cordon' | 'restart', 198 ) => { 199 e.stopPropagation() 200 try { 201 await runtimeActionMutation.mutateAsync({ runtimeId, action }) 202 const actionLabel = action === 'activate' 203 ? 'Runtime activated' 204 : action === 'drain' 205 ? 'Runtime draining' 206 : action === 'cordon' 207 ? 'Runtime cordoned' 208 : 'Restart requested' 209 toast.success(actionLabel) 210 } catch (err: unknown) { 211 toast.error(err instanceof Error ? err.message : 'Runtime action failed') 212 } 213 } 214 215 const customProviderConfigs = providerConfigs.filter((config) => config.type === 'custom') 216 const customConfigIds = new Set(customProviderConfigs.map((config) => config.id)) 217 const builtinOverrides = new Map( 218 providerConfigs 219 .filter((config) => config.type === 'builtin') 220 .map((config) => [config.id, config]), 221 ) 222 223 const builtinItems = providers 224 .filter((provider) => !customConfigIds.has(String(provider.id))) 225 .map((p) => ({ 226 id: p.id, 227 name: p.name, 228 type: 'builtin' as const, 229 models: p.models, 230 requiresApiKey: p.requiresApiKey, 231 isEnabled: builtinOverrides.get(String(p.id))?.isEnabled !== false, 232 isConnected: !p.requiresApiKey || Object.values(credentials).some((c) => c.provider === p.id), 233 })) 234 235 const customItems = customProviderConfigs.map((c) => ({ 236 id: c.id, 237 name: c.name, 238 type: 'custom' as const, 239 models: c.models, 240 requiresApiKey: c.requiresApiKey, 241 isEnabled: c.isEnabled, 242 isConnected: !c.requiresApiKey || !!c.credentialId, 243 })) 244 245 const allItems = [...builtinItems, ...customItems] 246 const enabledItems = allItems.filter((item) => item.isEnabled) 247 const disabledItems = allItems.filter((item) => !item.isEnabled) 248 const gatewayNameById = new Map(gatewayProfiles.map((gateway) => [gateway.id, gateway.name])) 249 const runtimeHealthByGateway = externalAgents.reduce<Record<string, { total: number; active: number; lastHeartbeatAt: number | null }>>((acc, runtime) => { 250 if (!runtime.gatewayProfileId) return acc 251 const current = acc[runtime.gatewayProfileId] || { total: 0, active: 0, lastHeartbeatAt: null } 252 current.total += 1 253 if (runtime.status === 'online' || runtime.status === 'idle') current.active += 1 254 if (typeof runtime.lastSeenAt === 'number' && (!current.lastHeartbeatAt || runtime.lastSeenAt > current.lastHeartbeatAt)) { 255 current.lastHeartbeatAt = runtime.lastSeenAt 256 } 257 acc[runtime.gatewayProfileId] = current 258 return acc 259 }, {}) 260 261 if ( 262 providersQuery.isPending 263 || providerConfigsQuery.isPending 264 || gatewayProfilesQuery.isPending 265 || externalAgentsQuery.isPending 266 || credentialsQuery.isPending 267 ) { 268 return <PageLoader label="Loading providers..." /> 269 } 270 271 return ( 272 <div className={`flex-1 overflow-y-auto ${inSidebar ? 'px-3 pb-4' : 'px-5 pb-6'}`}> 273 <div className="mb-4 flex items-center justify-between"> 274 <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Model Providers</div> 275 {!inSidebar && ( 276 <button 277 type="button" 278 onClick={() => handleEditGateway(null)} 279 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" 280 > 281 + Gateway 282 </button> 283 )} 284 </div> 285 <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}> 286 {enabledItems.map((item, idx) => ( 287 <div 288 key={item.id} 289 role="button" 290 tabIndex={0} 291 onClick={() => handleEdit(item.id)} 292 onKeyDown={(e) => { 293 if (e.key === 'Enter' || e.key === ' ') { 294 e.preventDefault() 295 handleEdit(item.id) 296 } 297 }} 298 className="w-full text-left p-4 rounded-[14px] border transition-all duration-200 299 cursor-pointer hover:bg-white/[0.02] bg-surface border-white/[0.06] hover:border-white/[0.12] hover:scale-[1.01]" 300 style={{ 301 animation: 'spring-in 0.5s var(--ease-spring) both', 302 animationDelay: `${idx * 0.05}s` 303 }} 304 > 305 <div className="flex items-center justify-between mb-1.5"> 306 <span className="font-display text-[14px] font-600 text-text truncate">{item.name}</span> 307 <div className="flex items-center gap-2 shrink-0"> 308 <span className={`text-[10px] font-600 px-2 py-0.5 rounded-[5px] uppercase tracking-wider 309 ${item.type === 'builtin' ? 'bg-white/[0.04] text-text-3' : 'bg-accent-bright/10 text-[#6366F1]'}`}> 310 {item.type === 'builtin' ? 'Built-in' : 'Custom'} 311 </span> 312 {!inSidebar && ( 313 <> 314 <div 315 onClick={(e) => handleToggle(e, item.id, item.isEnabled)} 316 className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0 317 ${item.isEnabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 318 > 319 <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all 320 ${item.isEnabled ? 'left-[18px]' : 'left-0.5'}`} 321 style={item.isEnabled ? { animation: 'spring-in 0.3s var(--ease-spring)' } : undefined} 322 /> 323 </div> 324 {item.type === 'custom' && ( 325 <button 326 onClick={(e) => handleDelete(e, item.id)} 327 className="text-text-3/40 hover:text-red-400 transition-colors p-0.5" 328 title="Delete provider" 329 > 330 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 331 <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> 332 </svg> 333 </button> 334 )} 335 </> 336 )} 337 <StatusDot 338 status={item.isConnected ? 'online' : 'idle'} 339 pulse={item.isConnected} 340 /> 341 </div> 342 </div> 343 <div className="text-[12px] text-text-3/60 font-mono truncate"> 344 {!inSidebar ? item.models.join(', ') : ( 345 <> 346 {item.models.slice(0, 3).join(', ')} 347 {item.models.length > 3 && ` +${item.models.length - 3}`} 348 </> 349 )} 350 </div> 351 </div> 352 ))} 353 </div> 354 {!inSidebar && disabledItems.length > 0 && ( 355 <> 356 <div className="mt-8 mb-4 flex items-center justify-between"> 357 <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Disabled Providers</div> 358 </div> 359 <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> 360 {disabledItems.map((item, idx) => ( 361 <div 362 key={item.id} 363 role="button" 364 tabIndex={0} 365 onClick={() => handleEdit(item.id)} 366 onKeyDown={(e) => { 367 if (e.key === 'Enter' || e.key === ' ') { 368 e.preventDefault() 369 handleEdit(item.id) 370 } 371 }} 372 className="w-full text-left p-4 rounded-[14px] border transition-all duration-200 373 cursor-pointer bg-surface/60 border-white/[0.06] hover:bg-white/[0.02] hover:border-white/[0.12]" 374 style={{ 375 animation: 'spring-in 0.5s var(--ease-spring) both', 376 animationDelay: `${(enabledItems.length + idx) * 0.05}s` 377 }} 378 > 379 <div className="flex items-center justify-between mb-1.5"> 380 <span className="font-display text-[14px] font-600 text-text truncate">{item.name}</span> 381 <div className="flex items-center gap-2 shrink-0"> 382 <span className={`text-[10px] font-600 px-2 py-0.5 rounded-[5px] uppercase tracking-wider 383 ${item.type === 'builtin' ? 'bg-white/[0.04] text-text-3' : 'bg-accent-bright/10 text-[#6366F1]'}`}> 384 {item.type === 'builtin' ? 'Built-in' : 'Custom'} 385 </span> 386 <div 387 onClick={(e) => handleToggle(e, item.id, item.isEnabled)} 388 className="w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0 bg-white/[0.08]" 389 > 390 <div className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-all" /> 391 </div> 392 {item.type === 'custom' && ( 393 <button 394 onClick={(e) => handleDelete(e, item.id)} 395 className="text-text-3/40 hover:text-red-400 transition-colors p-0.5" 396 title="Delete provider" 397 > 398 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 399 <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> 400 </svg> 401 </button> 402 )} 403 <StatusDot status="idle" pulse={false} /> 404 </div> 405 </div> 406 <div className="text-[12px] text-text-3/60 font-mono truncate"> 407 {item.models.slice(0, 3).join(', ')} 408 {item.models.length > 3 && ` +${item.models.length - 3}`} 409 </div> 410 </div> 411 ))} 412 </div> 413 </> 414 )} 415 416 <div className="mt-8 mb-4 flex items-center justify-between"> 417 <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">OpenClaw Gateways</div> 418 {!inSidebar && ( 419 <div className="flex items-center gap-2"> 420 <button 421 type="button" 422 onClick={() => handleEditGateway(null)} 423 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" 424 > 425 + New Gateway 426 </button> 427 </div> 428 )} 429 </div> 430 {!inSidebar && ( 431 <div className="mb-4 rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4"> 432 <OpenClawDeployPanel 433 compact 434 title="Deploy OpenClaw Control Planes" 435 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." 436 onApply={handleDeployApply} 437 /> 438 {deployDraft?.endpoint && ( 439 <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"> 440 <div> 441 <div className="text-[13px] font-700 text-emerald-300">Prepared gateway profile</div> 442 <div className="mt-1 text-[12px] text-text-3"> 443 {deployDraft.name || 'OpenClaw Gateway'} · <code className="text-text-2">{deployDraft.endpoint}</code> 444 </div> 445 </div> 446 <div className="flex flex-wrap gap-2"> 447 <button 448 type="button" 449 onClick={() => void handleSavePreparedGateway()} 450 disabled={savingDeploy} 451 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" 452 > 453 {savingDeploy ? 'Saving…' : 'Save Prepared Gateway'} 454 </button> 455 </div> 456 </div> 457 )} 458 </div> 459 )} 460 <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}> 461 {gatewayProfiles.map((gateway, idx) => ( 462 (() => { 463 const runtimeStats = runtimeHealthByGateway[gateway.id] || { total: 0, active: 0, lastHeartbeatAt: null } 464 const deployment = gateway.deployment || null 465 const stats = gateway.stats || null 466 return ( 467 <div 468 key={gateway.id} 469 role="button" 470 tabIndex={0} 471 onClick={() => handleEditGateway(gateway.id)} 472 onKeyDown={(e) => { 473 if (e.key === 'Enter' || e.key === ' ') { 474 e.preventDefault() 475 handleEditGateway(gateway.id) 476 } 477 }} 478 className="w-full text-left p-4 rounded-[14px] border transition-all duration-200 479 cursor-pointer hover:bg-white/[0.02] bg-surface border-white/[0.06] hover:border-white/[0.12] hover:scale-[1.01]" 480 style={{ 481 animation: 'spring-in 0.5s var(--ease-spring) both', 482 animationDelay: `${(allItems.length + idx) * 0.04}s`, 483 }} 484 > 485 <div className="flex items-center justify-between mb-2"> 486 <div className="min-w-0"> 487 <div className="font-display text-[14px] font-600 text-text truncate">{gateway.name}</div> 488 <div className="text-[11px] text-text-3/60 font-mono truncate">{gateway.endpoint}</div> 489 </div> 490 <div className="flex items-center gap-2 shrink-0"> 491 {gateway.isDefault && ( 492 <span className="text-[10px] font-700 px-2 py-0.5 rounded-[5px] bg-accent-bright/10 text-accent-bright uppercase tracking-wider">Default</span> 493 )} 494 <StatusDot 495 status={ 496 gateway.status === 'healthy' 497 ? 'online' 498 : gateway.status === 'degraded' 499 ? 'warning' 500 : gateway.status === 'offline' 501 ? 'offline' 502 : 'idle' 503 } 504 /> 505 </div> 506 </div> 507 <div className="text-[12px] text-text-3/70"> 508 {gateway.tags?.length ? gateway.tags.join(', ') : (gateway.notes || 'Dedicated OpenClaw control plane')} 509 </div> 510 {!inSidebar && ( 511 <div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-text-3/65"> 512 <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2"> 513 <div className="uppercase tracking-[0.08em] text-text-3/50">Deploy</div> 514 <div className="mt-1 text-text-2"> 515 {deployment?.method || 'manual'} 516 {deployment?.provider ? ` · ${deployment.provider}` : ''} 517 </div> 518 </div> 519 <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2"> 520 <div className="uppercase tracking-[0.08em] text-text-3/50">Route hints</div> 521 <div className="mt-1 text-text-2"> 522 {deployment?.useCase || 'general'} 523 {deployment?.exposure ? ` · ${deployment.exposure}` : ''} 524 </div> 525 </div> 526 <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2"> 527 <div className="uppercase tracking-[0.08em] text-text-3/50">Nodes / devices</div> 528 <div className="mt-1 text-text-2"> 529 {stats?.connectedNodeCount ?? 0}/{stats?.nodeCount ?? 0} nodes · {stats?.pairedDeviceCount ?? 0} devices 530 </div> 531 </div> 532 <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2"> 533 <div className="uppercase tracking-[0.08em] text-text-3/50">Runtimes</div> 534 <div className="mt-1 text-text-2"> 535 {runtimeStats.active}/{runtimeStats.total} active 536 </div> 537 </div> 538 </div> 539 )} 540 {!inSidebar && deployment?.lastVerifiedMessage && ( 541 <div className="mt-3 text-[11px] text-text-3/60"> 542 {deployment.lastVerifiedMessage} 543 </div> 544 )} 545 {!inSidebar && ( 546 <div className="mt-3 flex items-center gap-2"> 547 <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"> 548 Health 549 </button> 550 <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"> 551 Clone 552 </button> 553 <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"> 554 Delete 555 </button> 556 </div> 557 )} 558 </div> 559 ) 560 })() 561 ))} 562 {gatewayProfiles.length === 0 && ( 563 <div className="p-4 rounded-[14px] border border-dashed border-white/[0.08] text-[13px] text-text-3/70"> 564 No gateway profiles yet. Use Smart Deploy above for a local runtime, a Docker VPS bundle, or a hosted OpenClaw deployment profile. 565 </div> 566 )} 567 </div> 568 569 {!inSidebar && ( 570 <> 571 <div className="mt-8 mb-4 flex items-center justify-between"> 572 <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">External Agent Runtimes</div> 573 <div className="text-[11px] text-text-3/60">Direct registration + heartbeat</div> 574 </div> 575 <div className="mb-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-[12px] text-text-3/70"> 576 External workers can register themselves at <code className="text-text-2">/api/external-agents/register</code> and then send heartbeats to 577 {' '} 578 <code className="text-text-2">/api/external-agents/<id>/heartbeat</code>. 579 </div> 580 <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"> 581 {externalAgents.map((runtime) => ( 582 <div key={runtime.id} className="p-4 rounded-[14px] bg-surface border border-white/[0.06]"> 583 <div className="flex items-center justify-between gap-3 mb-2"> 584 <div className="min-w-0"> 585 <div className="font-display text-[14px] font-600 text-text truncate">{runtime.name}</div> 586 <div className="text-[11px] text-text-3/60 truncate"> 587 {runtime.sourceType} · {runtime.transport || 'custom'} 588 {runtime.version ? ` · ${runtime.version}` : ''} 589 </div> 590 </div> 591 <div className="flex flex-wrap items-center justify-end gap-2"> 592 <span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${ 593 runtime.lifecycleState === 'cordoned' 594 ? 'bg-red-400/10 text-red-300' 595 : runtime.lifecycleState === 'draining' 596 ? 'bg-amber-400/10 text-amber-300' 597 : 'bg-blue-400/10 text-blue-300' 598 }`}> 599 {runtime.lifecycleState || 'active'} 600 </span> 601 <span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${ 602 runtime.status === 'online' 603 ? 'bg-emerald-400/10 text-emerald-300' 604 : runtime.status === 'stale' 605 ? 'bg-amber-400/10 text-amber-300' 606 : 'bg-white/[0.04] text-text-3' 607 }`}> 608 {runtime.status} 609 </span> 610 </div> 611 </div> 612 <div className="grid grid-cols-2 gap-2 text-[11px] text-text-3/65"> 613 <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2"> 614 <div className="uppercase tracking-[0.08em] text-text-3/50">Provider</div> 615 <div className="mt-1 text-text-2"> 616 {runtime.provider || 'No provider'} 617 {runtime.model ? ` · ${runtime.model}` : ''} 618 </div> 619 </div> 620 <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2"> 621 <div className="uppercase tracking-[0.08em] text-text-3/50">Gateway</div> 622 <div className="mt-1 text-text-2"> 623 {runtime.gatewayProfileId ? (gatewayNameById.get(runtime.gatewayProfileId) || runtime.gatewayProfileId) : 'Standalone'} 624 </div> 625 </div> 626 <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2"> 627 <div className="uppercase tracking-[0.08em] text-text-3/50">Template</div> 628 <div className="mt-1 text-text-2">{runtime.gatewayUseCase || 'general'}</div> 629 </div> 630 <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2"> 631 <div className="uppercase tracking-[0.08em] text-text-3/50">Last seen</div> 632 <div className="mt-1 text-text-2">{formatRuntimeTimestamp(runtime.lastSeenAt || runtime.lastHeartbeatAt)}</div> 633 </div> 634 </div> 635 <div className="text-[11px] text-text-3/55 mt-3 font-mono truncate">{runtime.endpoint || runtime.workspace || runtime.id}</div> 636 {runtime.gatewayTags?.length ? ( 637 <div className="mt-3 flex flex-wrap gap-1.5"> 638 {runtime.gatewayTags.slice(0, 6).map((tag) => ( 639 <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"> 640 {tag} 641 </span> 642 ))} 643 </div> 644 ) : null} 645 {runtime.lastHealthNote && ( 646 <div className="mt-3 text-[11px] text-text-3/65 leading-relaxed"> 647 {runtime.lastHealthNote} 648 </div> 649 )} 650 <div className="mt-3 flex flex-wrap gap-2"> 651 <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"> 652 Activate 653 </button> 654 <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"> 655 Drain 656 </button> 657 <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"> 658 Cordon 659 </button> 660 <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"> 661 Restart 662 </button> 663 </div> 664 </div> 665 ))} 666 {externalAgents.length === 0 && ( 667 <div className="p-4 rounded-[14px] border border-dashed border-white/[0.08] text-[13px] text-text-3/70"> 668 No external runtimes have registered yet. 669 </div> 670 )} 671 </div> 672 </> 673 )} 674 </div> 675 ) 676 }