'use client' import { useState, useEffect, useCallback } from 'react' import { useTranslations } from 'next-intl' 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 t = useTranslations('multiGateway') const [gateways, setGateways] = useState([]) const [directConnections, setDirectConnections] = useState([]) const [discoveredGateways, setDiscoveredGateways] = useState([]) const [loading, setLoading] = useState(true) const [showAdd, setShowAdd] = useState(false) const [probing, setProbing] = useState(null) const [healthByGatewayId, setHealthByGatewayId] = useState>(new Map()) const [historyByGatewayId, setHistoryByGatewayId] = useState>({}) 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 = {} 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() 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 (
{/* Header */}

{t('title')}

{t('description')}

{/* Current connection info (shown only for unmanaged/unknown connections). */} {shouldShowConnectionSummary && (
{connection.isConnected ? t('connected') : t('disconnected')}
{connection.url || t('noActiveConnection')} {connection.latency != null && ` (${connection.latency}ms)`}
)} {/* Add Form */} {showAdd && ( { fetchGateways(); setShowAdd(false) }} onCancel={() => setShowAdd(false)} /> )} {/* Gateway List */} {loading ? (
{t('loadingGateways')}
) : gateways.length === 0 ? (

{t('noGateways')}

{t('addGatewayHint')}

) : (
{gateways.map(gw => ( setPrimary(gw)} onDelete={() => deleteGateway(gw.id)} onConnect={() => connectTo(gw)} onProbe={() => probeGateway(gw)} /> ))}
)} {/* Discovered OS-Level Gateways (exclude already-registered ones) */} {discoveredGateways.filter(dg => !gateways.some(gw => gw.port === dg.port)).length > 0 && (

{t('discoveredGateways')}

{t('discoveredGatewaysDesc')}

{discoveredGateways .filter(dg => !gateways.some(gw => gw.port === dg.port)) .map(dg => (
{dg.user} {dg.active ? t('running') : t('stopped')} {dg.tailscale?.mode && ( TS:{dg.tailscale.mode} )}
127.0.0.1:{dg.port} {t('bind')}: {dg.bind} {t('mode')}: {dg.mode}
))}
)} {/* Direct CLI Connections */}

{t('directCliConnections')}

{t('directCliDesc')}

{directConnections.length === 0 ? (

{t('noDirectConnections')}

{t('useApiConnect')} POST /api/connect {t('toRegisterCli')}

) : (
{directConnections.map(conn => (
{conn.agent_name} {conn.tool_name}{conn.tool_version ? ` v${conn.tool_version}` : ''} {conn.status.toUpperCase()}
{t('role')}: {conn.agent_role || 'cli'} {t('heartbeat')}: {conn.last_heartbeat ? new Date(conn.last_heartbeat * 1000).toLocaleString() : t('never')} {conn.connection_id.slice(0, 8)}...
{conn.status === 'connected' && ( )}
))}
)}
) } 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 t = useTranslations('multiGateway') const statusColors: Record = { 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() : t('neverProbed') const compatibilityWarning = health?.compatibility_warning return (

{gateway.name}

{gateway.is_primary ? ( {t('primary')} ) : null} {isCurrentlyConnected && ( {t('connectedBadge')} )}
{gateway.host}:{gateway.port} {t('token')}: {gateway.token_set ? t('tokenSet') : t('tokenNone')} {gateway.latency != null && {t('latency')}: {gateway.latency}ms} {t('last')}: {lastSeen}
{health?.gateway_version && (
{t('gatewayVersion')}: {health.gateway_version}
)} {compatibilityWarning && (
{compatibilityWarning}
)}
{timelineEntries.length > 0 ? (
{timelineEntries.map((entry) => ( ))}
) : ( {t('noHistory')} )} {t('colorKey')} {latestEntry?.latency != null && ( {t('lastLatency', { ms: latestEntry.latency })} )}
{!isCurrentlyConnected && ( )} {!gateway.is_primary && ( <> )}
) } function AddGatewayForm({ onAdded, onCancel }: { onAdded: () => void; onCancel: () => void }) { const t = useTranslations('multiGateway') 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 || t('failedToAdd')) return } onAdded() } catch { setError(t('networkError')) } finally { setSaving(false) } } return (

{t('addGatewayTitle')}

setForm({ ...form, name: e.target.value })} placeholder={t('namePlaceholder')} 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 />
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 />
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 />
setForm({ ...form, token: e.target.value })} placeholder={t('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" />
{error &&

{error}

}
) }