HuggingClaw-MissionControl / src /components /panels /multi-gateway-panel.tsx
nyk
feat: full i18n — 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified
Raw
History Blame Contribute Delete
25.5 kB
'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<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">{t('title')}</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t('description')}
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={probeAll}
variant="secondary"
size="sm"
>
{t('probeAll')}
</Button>
<Button
onClick={() => setShowAdd(!showAdd)}
size="sm"
>
{t('addGateway')}
</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 ? t('connected') : t('disconnected')}
</div>
<div className="text-xs text-muted-foreground">
{connection.url || t('noActiveConnection')}
{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">{t('loadingGateways')}</div>
) : gateways.length === 0 ? (
<div className="text-center py-12 bg-card border border-border rounded-lg">
<p className="text-sm text-muted-foreground">{t('noGateways')}</p>
<p className="text-xs text-muted-foreground mt-1">{t('addGatewayHint')}</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">{t('discoveredGateways')}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{t('discoveredGatewaysDesc')}
</p>
</div>
<Button
onClick={fetchDiscovered}
variant="secondary"
size="xs"
className="text-2xs"
>
{t('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 ? t('running') : t('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>{t('bind')}: {dg.bind}</span>
<span>{t('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"
>
{t('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">{t('directCliConnections')}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{t('directCliDesc')}
</p>
</div>
<Button
onClick={fetchDirectConnections}
variant="secondary"
size="xs"
className="text-2xs"
>
{t('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">{t('noDirectConnections')}</p>
<p className="text-2xs text-muted-foreground mt-1">
{t('useApiConnect')} <code className="font-mono bg-secondary px-1 rounded">POST /api/connect</code> {t('toRegisterCli')}
</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>{t('role')}: {conn.agent_role || 'cli'}</span>
<span>{t('heartbeat')}: {conn.last_heartbeat ? new Date(conn.last_heartbeat * 1000).toLocaleString() : t('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"
>
{t('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 t = useTranslations('multiGateway')
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()
: t('neverProbed')
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">
{t('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">
{t('connectedBadge')}
</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>{t('token')}: {gateway.token_set ? t('tokenSet') : t('tokenNone')}</span>
{gateway.latency != null && <span>{t('latency')}: {gateway.latency}ms</span>}
<span>{t('last')}: {lastSeen}</span>
</div>
{health?.gateway_version && (
<div className="mt-1 text-2xs text-muted-foreground">
{t('gatewayVersion')}: <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">{t('noHistory')}</span>
)}
<span title={t('colorKeyTitle')} className="text-2xs text-muted-foreground">
{t('colorKey')}
</span>
{latestEntry?.latency != null && (
<span className="text-2xs font-medium">{t('lastLatency', { ms: latestEntry.latency })}</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={t('probeGateway')}
>
{isProbing ? t('probing') : t('probe')}
</Button>
{!isCurrentlyConnected && (
<Button
onClick={onConnect}
size="xs"
className="text-2xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30"
title={t('connectToGateway')}
>
{t('connect')}
</Button>
)}
{!gateway.is_primary && (
<>
<Button
onClick={onSetPrimary}
variant="secondary"
size="xs"
className="text-2xs"
title={t('setPrimaryTitle')}
>
{t('setPrimary')}
</Button>
<Button
onClick={onDelete}
variant="ghost"
size="icon-xs"
className="hover:text-red-400 hover:bg-red-500/10"
title={t('removeGateway')}
>
<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 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 (
<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">{t('addGatewayTitle')}</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">{t('name')}</label>
<input
type="text"
value={form.name}
onChange={e => 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
/>
</div>
<div>
<label className="block text-2xs text-muted-foreground mb-1">{t('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">{t('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">{t('token')}</label>
<input
type="password"
value={form.token}
onChange={e => 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"
/>
</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">
{t('cancel')}
</Button>
<Button type="submit" disabled={saving} size="sm">
{saving ? t('adding') : t('addGatewaySubmit')}
</Button>
</div>
</form>
)
}