mission-control/src/components/panels/multi-gateway-panel.tsx

680 lines
25 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { useMissionControl } from '@/store'
import { useWebSocket } from '@/lib/websocket'
import { buildGatewayWebSocketUrl } from '@/lib/gateway-url'
interface Gateway {
id: number
name: string
host: string
port: number
token_set: boolean
is_primary: number
status: string
last_seen: number | null
latency: number | null
sessions_count: number
agents_count: number
created_at: number
updated_at: number
}
interface DirectConnection {
id: number
agent_id: number
tool_name: string
tool_version: string | null
connection_id: string
status: string
last_heartbeat: number | null
metadata: string | null
created_at: number
agent_name: string
agent_status: string
agent_role: string
}
interface GatewayHealthProbe {
id: number
name: string
status: 'online' | 'offline' | 'error'
latency: number | null
gateway_version?: string | null
compatibility_warning?: string
error?: string
}
interface GatewayHealthLogEntry {
status: string
latency: number | null
probed_at: number
error: string | null
}
interface GatewayHistory {
gatewayId: number
name: string | null
entries: GatewayHealthLogEntry[]
}
interface DiscoveredGateway {
user: string
port: number
bind: string
mode: string
active: boolean
tailscale?: { mode: string }
}
export function MultiGatewayPanel() {
const [gateways, setGateways] = useState<Gateway[]>([])
const [directConnections, setDirectConnections] = useState<DirectConnection[]>([])
const [discoveredGateways, setDiscoveredGateways] = useState<DiscoveredGateway[]>([])
const [loading, setLoading] = useState(true)
const [showAdd, setShowAdd] = useState(false)
const [probing, setProbing] = useState<number | null>(null)
const [healthByGatewayId, setHealthByGatewayId] = useState<Map<number, GatewayHealthProbe>>(new Map())
const [historyByGatewayId, setHistoryByGatewayId] = useState<Record<number, GatewayHistory>>({})
const { connection } = useMissionControl()
const { connect } = useWebSocket()
const fetchGateways = useCallback(async () => {
try {
const res = await fetch('/api/gateways')
const data = await res.json()
setGateways(data.gateways || [])
} catch { /* ignore */ }
setLoading(false)
}, [])
const fetchDirectConnections = useCallback(async () => {
try {
const res = await fetch('/api/connect')
const data = await res.json()
setDirectConnections(data.connections || [])
} catch { /* ignore */ }
}, [])
const fetchDiscovered = useCallback(async () => {
try {
const res = await fetch('/api/gateways/discover')
const data = await res.json()
setDiscoveredGateways(data.gateways || [])
} catch { /* ignore */ }
}, [])
const fetchHistory = useCallback(async () => {
try {
const res = await fetch('/api/gateways/health/history')
const data = await res.json()
const map: Record<number, GatewayHistory> = {}
for (const entry of data.history || []) {
map[entry.gatewayId] = entry
}
setHistoryByGatewayId(map)
} catch {
setHistoryByGatewayId({})
}
}, [])
useEffect(() => { fetchGateways(); fetchDirectConnections(); fetchDiscovered(); fetchHistory() }, [fetchGateways, fetchDirectConnections, fetchDiscovered, fetchHistory])
const gatewayMatchesConnection = useCallback((gw: Gateway): boolean => {
const url = connection.url
if (!url) return false
const normalizedConn = url.toLowerCase()
const normalizedHost = String(gw.host || '').toLowerCase()
if (normalizedHost && normalizedConn.includes(normalizedHost)) return true
if (normalizedConn.includes(`:${gw.port}`)) return true
try {
const derivedWs = buildGatewayWebSocketUrl({
host: gw.host,
port: gw.port,
browserProtocol: window.location.protocol,
}).toLowerCase()
return normalizedConn.includes(derivedWs)
} catch {
return false
}
}, [connection.url])
const shouldShowConnectionSummary =
gateways.length === 0 ||
!gateways.some(gatewayMatchesConnection)
const setPrimary = async (gw: Gateway) => {
await fetch('/api/gateways', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: gw.id, is_primary: 1 }),
})
fetchGateways()
fetchHistory()
}
const deleteGateway = async (id: number) => {
await fetch('/api/gateways', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
fetchGateways()
fetchHistory()
}
const connectTo = async (gw: Gateway) => {
try {
const res = await fetch('/api/gateways/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: gw.id }),
})
if (!res.ok) return
const payload = await res.json()
const wsUrl = String(payload?.ws_url || buildGatewayWebSocketUrl({
host: gw.host,
port: gw.port,
browserProtocol: window.location.protocol,
}))
const token = String(payload?.token || '')
connect(wsUrl, token)
} catch {
// ignore: connection status will remain disconnected
}
}
const probeAll = async () => {
try {
const res = await fetch("/api/gateways/health", { method: "POST" })
const data = await res.json().catch(() => ({}))
const rows = Array.isArray(data?.results) ? data.results as GatewayHealthProbe[] : []
const mapped = new Map<number, GatewayHealthProbe>()
for (const row of rows) {
if (typeof row?.id === 'number') mapped.set(row.id, row)
}
setHealthByGatewayId(mapped)
} catch { /* ignore */ }
fetchGateways()
fetchHistory()
}
const probeGateway = async (gw: Gateway) => {
setProbing(gw.id)
await probeAll()
setProbing(null)
}
const disconnectCli = async (connectionId: string) => {
try {
await fetch('/api/connect', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
})
fetchDirectConnections()
} catch { /* ignore */ }
}
return (
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Gateway Manager</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Manage multiple OpenClaw gateway connections
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={probeAll}
variant="secondary"
size="sm"
>
Probe All
</Button>
<Button
onClick={() => setShowAdd(!showAdd)}
size="sm"
>
+ Add Gateway
</Button>
</div>
</div>
{/* Current connection info (shown only for unmanaged/unknown connections). */}
{shouldShowConnectionSummary && (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-3">
<span className={`w-2.5 h-2.5 rounded-full ${connection.isConnected ? 'bg-green-500' : 'bg-red-500 animate-pulse'}`} />
<div>
<div className="text-sm font-medium text-foreground">
{connection.isConnected ? 'Connected' : 'Disconnected'}
</div>
<div className="text-xs text-muted-foreground">
{connection.url || 'No active connection'}
{connection.latency != null && ` (${connection.latency}ms)`}
</div>
</div>
</div>
</div>
)}
{/* Add Form */}
{showAdd && (
<AddGatewayForm onAdded={() => { fetchGateways(); setShowAdd(false) }} onCancel={() => setShowAdd(false)} />
)}
{/* Gateway List */}
{loading ? (
<div className="text-center text-xs text-muted-foreground py-8">Loading gateways...</div>
) : gateways.length === 0 ? (
<div className="text-center py-12 bg-card border border-border rounded-lg">
<p className="text-sm text-muted-foreground">No gateways configured</p>
<p className="text-xs text-muted-foreground mt-1">Add a gateway to start managing connections</p>
</div>
) : (
<div className="space-y-2">
{gateways.map(gw => (
<GatewayCard
key={gw.id}
gateway={gw}
health={healthByGatewayId.get(gw.id)}
historyEntries={historyByGatewayId[gw.id]?.entries || []}
isProbing={probing === gw.id}
isCurrentlyConnected={gatewayMatchesConnection(gw)}
onSetPrimary={() => setPrimary(gw)}
onDelete={() => deleteGateway(gw.id)}
onConnect={() => connectTo(gw)}
onProbe={() => probeGateway(gw)}
/>
))}
</div>
)}
{/* Discovered OS-Level Gateways (exclude already-registered ones) */}
{discoveredGateways.filter(dg => !gateways.some(gw => gw.port === dg.port)).length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-foreground">Discovered Gateways</h3>
<p className="text-xs text-muted-foreground mt-0.5">
OpenClaw gateways found on this machine
</p>
</div>
<Button
onClick={fetchDiscovered}
variant="secondary"
size="xs"
className="text-2xs"
>
Refresh
</Button>
</div>
<div className="space-y-2">
{discoveredGateways
.filter(dg => !gateways.some(gw => gw.port === dg.port))
.map(dg => (
<div key={`${dg.user}-${dg.port}`} className="bg-card border border-border rounded-lg p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${dg.active ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm font-semibold text-foreground">{dg.user}</span>
<span className={`text-2xs px-1.5 py-0.5 rounded font-medium ${
dg.active
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'bg-red-500/20 text-red-400 border border-red-500/30'
}`}>
{dg.active ? 'RUNNING' : 'STOPPED'}
</span>
{dg.tailscale?.mode && (
<span className="text-2xs px-1.5 py-0.5 rounded bg-violet-500/20 text-violet-400 border border-violet-500/30 font-medium">
TS:{dg.tailscale.mode}
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">127.0.0.1:{dg.port}</span>
<span>Bind: {dg.bind}</span>
<span>Mode: {dg.mode}</span>
</div>
</div>
<Button
onClick={async () => {
await fetch('/api/gateways', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: dg.user,
host: '127.0.0.1',
port: dg.port,
is_primary: false,
}),
})
fetchGateways()
fetchDiscovered()
}}
variant="secondary"
size="xs"
className="text-2xs"
>
Register
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* Direct CLI Connections */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-foreground">Direct CLI Connections</h3>
<p className="text-xs text-muted-foreground mt-0.5">
CLI tools connected directly without a gateway
</p>
</div>
<Button
onClick={fetchDirectConnections}
variant="secondary"
size="xs"
className="text-2xs"
>
Refresh
</Button>
</div>
{directConnections.length === 0 ? (
<div className="text-center py-8 bg-card border border-border rounded-lg">
<p className="text-xs text-muted-foreground">No direct CLI connections</p>
<p className="text-2xs text-muted-foreground mt-1">
Use <code className="font-mono bg-secondary px-1 rounded">POST /api/connect</code> to register a CLI tool
</p>
</div>
) : (
<div className="space-y-2">
{directConnections.map(conn => (
<div key={conn.id} className="bg-card border border-border rounded-lg p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${conn.status === 'connected' ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm font-semibold text-foreground">{conn.agent_name}</span>
<span className="text-2xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 border border-blue-500/30 font-medium">
{conn.tool_name}{conn.tool_version ? ` v${conn.tool_version}` : ''}
</span>
<span className={`text-2xs px-1.5 py-0.5 rounded font-medium ${
conn.status === 'connected'
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'bg-red-500/20 text-red-400 border border-red-500/30'
}`}>
{conn.status.toUpperCase()}
</span>
</div>
<div className="flex items-center gap-4 mt-1.5 text-xs text-muted-foreground">
<span>Role: {conn.agent_role || 'cli'}</span>
<span>Heartbeat: {conn.last_heartbeat ? new Date(conn.last_heartbeat * 1000).toLocaleString() : 'Never'}</span>
<span className="font-mono text-2xs">{conn.connection_id.slice(0, 8)}...</span>
</div>
</div>
{conn.status === 'connected' && (
<Button
onClick={() => disconnectCli(conn.connection_id)}
variant="ghost"
size="xs"
className="text-2xs text-red-400 hover:bg-red-500/10"
>
Disconnect
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
function GatewayCard({ gateway, health, historyEntries = [], isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: {
gateway: Gateway
health?: GatewayHealthProbe
historyEntries?: GatewayHealthLogEntry[]
isProbing: boolean
isCurrentlyConnected: boolean
onSetPrimary: () => void
onDelete: () => void
onConnect: () => void
onProbe: () => void
}) {
const statusColors: Record<string, string> = {
online: 'bg-green-500',
offline: 'bg-red-500',
error: 'bg-amber-500',
timeout: 'bg-amber-500',
unknown: 'bg-muted-foreground/30',
}
const timelineEntries = historyEntries.length > 0 ? [...historyEntries].slice(0, 10).reverse() : []
const latestEntry = historyEntries[0]
const lastSeen = gateway.last_seen
? new Date(gateway.last_seen * 1000).toLocaleString()
: 'Never probed'
const compatibilityWarning = health?.compatibility_warning
return (
<div className={`bg-card border rounded-lg p-4 transition-smooth ${
isCurrentlyConnected ? 'border-green-500/30 bg-green-500/5' : 'border-border'
}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${isCurrentlyConnected ? 'bg-green-500' : (statusColors[gateway.status] || statusColors.unknown)}`} />
<h3 className="text-sm font-semibold text-foreground">{gateway.name}</h3>
{gateway.is_primary ? (
<span className="text-2xs px-1.5 py-0.5 rounded bg-primary/20 text-primary border border-primary/30 font-medium">
PRIMARY
</span>
) : null}
{isCurrentlyConnected && (
<span className="text-2xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400 border border-green-500/30 font-medium">
CONNECTED
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{gateway.host}:{gateway.port}</span>
<span>Token: {gateway.token_set ? 'Set' : 'None'}</span>
{gateway.latency != null && <span>Latency: {gateway.latency}ms</span>}
<span>Last: {lastSeen}</span>
</div>
{health?.gateway_version && (
<div className="mt-1 text-2xs text-muted-foreground">
Gateway version: <span className="font-mono text-foreground/80">{health.gateway_version}</span>
</div>
)}
{compatibilityWarning && (
<div className="mt-1.5 text-2xs rounded border border-amber-500/30 bg-amber-500/10 text-amber-300 px-2 py-1">
{compatibilityWarning}
</div>
)}
<div className="flex flex-wrap items-center gap-2 mt-3 text-2xs text-muted-foreground">
{timelineEntries.length > 0 ? (
<div className="flex items-center gap-0.5">
{timelineEntries.map((entry) => (
<span
key={`${entry.probed_at}-${entry.status}`}
className={`w-2.5 h-2.5 rounded-full ${statusColors[entry.status] || statusColors.unknown}`}
title={`${entry.status} ${entry.latency != null ? `(${entry.latency}ms)` : '(n/a)'} @ ${new Date(entry.probed_at * 1000).toLocaleTimeString()}${entry.error ? `${entry.error}` : ''}`}
/>
))}
</div>
) : (
<span className="text-2xs text-muted-foreground">No history yet</span>
)}
<span title="Green = online; amber = error; red = offline" className="text-2xs text-muted-foreground">
Color key
</span>
{latestEntry?.latency != null && (
<span className="text-2xs font-medium">Last latency: {latestEntry.latency}ms</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
<Button
onClick={onProbe}
disabled={isProbing}
variant="secondary"
size="xs"
className="text-2xs"
title="Probe gateway"
>
{isProbing ? 'Probing...' : 'Probe'}
</Button>
{!isCurrentlyConnected && (
<Button
onClick={onConnect}
size="xs"
className="text-2xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30"
title="Connect to this gateway"
>
Connect
</Button>
)}
{!gateway.is_primary && (
<>
<Button
onClick={onSetPrimary}
variant="secondary"
size="xs"
className="text-2xs"
title="Set as primary"
>
Set Primary
</Button>
<Button
onClick={onDelete}
variant="ghost"
size="icon-xs"
className="hover:text-red-400 hover:bg-red-500/10"
title="Remove gateway"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M3 4h10M6 4V3h4v1M5 4v8.5a.5.5 0 00.5.5h5a.5.5 0 00.5-.5V4" />
</svg>
</Button>
</>
)}
</div>
</div>
</div>
)
}
function AddGatewayForm({ onAdded, onCancel }: { onAdded: () => void; onCancel: () => void }) {
const [form, setForm] = useState({ name: '', host: '127.0.0.1', port: '18789', token: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSaving(true)
try {
const res = await fetch('/api/gateways', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
host: form.host,
port: parseInt(form.port),
token: form.token,
is_primary: false,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Failed to add gateway')
return
}
onAdded()
} catch {
setError('Network error')
} finally {
setSaving(false)
}
}
return (
<form onSubmit={handleSubmit} className="bg-card border border-primary/20 rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold text-foreground">Add Gateway</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
<div>
<label className="block text-2xs text-muted-foreground mb-1">Name</label>
<input
type="text"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
placeholder="e.g., primary"
className="w-full h-8 px-2.5 rounded-md bg-secondary border border-border text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</div>
<div>
<label className="block text-2xs text-muted-foreground mb-1">Host</label>
<input
type="text"
value={form.host}
onChange={e => setForm({ ...form, host: e.target.value })}
className="w-full h-8 px-2.5 rounded-md bg-secondary border border-border text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</div>
<div>
<label className="block text-2xs text-muted-foreground mb-1">Port</label>
<input
type="number"
value={form.port}
onChange={e => setForm({ ...form, port: e.target.value })}
className="w-full h-8 px-2.5 rounded-md bg-secondary border border-border text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</div>
<div>
<label className="block text-2xs text-muted-foreground mb-1">Token</label>
<input
type="password"
value={form.token}
onChange={e => setForm({ ...form, token: e.target.value })}
placeholder="Optional"
className="w-full h-8 px-2.5 rounded-md bg-secondary border border-border text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex gap-2 pt-1">
<Button type="button" onClick={onCancel} variant="outline" size="sm">
Cancel
</Button>
<Button type="submit" disabled={saving} size="sm">
{saving ? 'Adding...' : 'Add Gateway'}
</Button>
</div>
</form>
)
}