/ src / components / providers / provider-list.tsx
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/&lt;id&gt;/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  }