'use client' import { useState, useEffect, useCallback } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' interface PresenceEntry { id: string clientId: string displayName: string platform: string version: string roles: string[] connectedAt: number lastActivity: number host?: string ip?: string status: 'online' | 'idle' | 'offline' } interface DeviceTokenSummary { role: string scopes?: string[] createdAtMs?: number rotatedAtMs?: number revokedAtMs?: number lastUsedAtMs?: number } interface PendingDevice { requestId: string deviceId: string displayName?: string role?: string remoteIp?: string isRepair?: boolean ts?: number } interface PairedDevice { id: string deviceId: string displayName: string publicKey?: string pairedAt?: number lastSeen?: number trusted?: boolean roles?: string[] scopes?: string[] tokens?: DeviceTokenSummary[] createdAtMs?: number approvedAtMs?: number } type Tab = 'instances' | 'devices' function relativeTime(ts: number): string { if (!ts) return '--' const now = Date.now() const diffMs = now - (ts < 1e12 ? ts * 1000 : ts) if (diffMs < 0) return 'just now' const seconds = Math.floor(diffMs / 1000) if (seconds < 60) return `${seconds}s ago` const minutes = Math.floor(seconds / 60) if (minutes < 60) return `${minutes}m ago` const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours}h ago` const days = Math.floor(hours / 24) return `${days}d ago` } function statusColor(status: PresenceEntry['status']): string { switch (status) { case 'online': return 'bg-green-500/20 text-green-400 border-green-500/30' case 'idle': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' case 'offline': return 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30' default: return 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30' } } async function deviceAction( action: string, params: Record, ): Promise<{ ok: boolean; error?: string; data?: Record }> { try { const res = await fetch('/api/nodes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...params }), }) const data = await res.json() if (!res.ok) return { ok: false, error: data.error || `Request failed (${res.status})` } return { ok: true, data } } catch { return { ok: false, error: 'Network error' } } } export function NodesPanel() { const t = useTranslations('nodes') const [tab, setTab] = useState('instances') const [nodes, setNodes] = useState([]) const [devices, setDevices] = useState([]) const [pendingDevices, setPendingDevices] = useState([]) const [connected, setConnected] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const fetchNodes = useCallback(async () => { try { const res = await fetch('/api/nodes') if (!res.ok) { setError('Failed to fetch nodes'); return } const data = await res.json() setNodes(data.nodes || data.entries || []) setConnected(data.connected !== false) setError(null) } catch { setError('Failed to fetch nodes') } finally { setLoading(false) } }, []) const fetchDevices = useCallback(async () => { try { const res = await fetch('/api/nodes?action=devices') if (!res.ok) return const data = await res.json() setDevices(data.paired || data.devices || []) setPendingDevices(data.pending || []) } catch { // silent fallback } }, []) useEffect(() => { fetchNodes() fetchDevices() const interval = setInterval(() => { fetchNodes() fetchDevices() }, 30000) return () => clearInterval(interval) }, [fetchNodes, fetchDevices]) const pendingCount = pendingDevices.length const totalDeviceCount = devices.length + pendingCount return (

{t('title')}

{connected ? t('gatewayConnected') : t('gatewayUnreachable')}
{/* Tab bar */}
{error && (
{error}
)} {loading ? (
{t('loading')}
) : tab === 'instances' ? ( ) : ( )}
) } function InstancesTab({ nodes }: { nodes: PresenceEntry[] }) { const t = useTranslations('nodes') if (nodes.length === 0) { return (
{t('noInstances')}
) } return (
{nodes.map((node) => ( ))}
{t('colName')} {t('colClientId')} {t('colPlatform')} {t('colVersion')} {t('colRoles')} {t('colStatus')} {t('colConnected')} {t('colLastActivity')} {t('colHostIp')}
{node.displayName} {node.clientId?.slice(0, 12)}... {node.platform} {node.version}
{(node.roles || []).map((role) => ( {role} ))}
{node.status} {relativeTime(node.connectedAt)} {relativeTime(node.lastActivity)} {node.host || node.ip || '--'}
) } function DevicesTab({ devices, pendingDevices, onRefresh, }: { devices: PairedDevice[] pendingDevices: PendingDevice[] onRefresh: () => void }) { return (
{pendingDevices.length > 0 && ( )}
) } function PendingDevicesSection({ devices, onRefresh, }: { devices: PendingDevice[] onRefresh: () => void }) { const t = useTranslations('nodes') const [actionLoading, setActionLoading] = useState(null) const [actionError, setActionError] = useState(null) async function handleAction(action: 'approve' | 'reject', requestId: string) { setActionError(null) setActionLoading(`${action}-${requestId}`) const result = await deviceAction(action, { requestId }) setActionLoading(null) if (!result.ok) { setActionError(result.error || 'Action failed') } else { onRefresh() } } return (

{t('pendingPairingRequests', { count: devices.length })}

{actionError && (
{actionError}
)}
{devices.map((device) => (
{device.displayName || device.deviceId}
{device.deviceId?.slice(0, 16)} {device.role && ( {device.role} )} {device.remoteIp && {device.remoteIp}} {device.isRepair && ( repair )} {device.ts && {relativeTime(device.ts)}}
))}
) } function PairedDevicesSection({ devices, onRefresh, }: { devices: PairedDevice[] onRefresh: () => void }) { const t = useTranslations('nodes') const [actionLoading, setActionLoading] = useState(null) const [actionError, setActionError] = useState(null) const [confirmRevoke, setConfirmRevoke] = useState(null) const [expandedDevice, setExpandedDevice] = useState(null) async function handleRotateToken(deviceId: string, role?: string) { setActionError(null) setActionLoading(`rotate-${deviceId}`) const result = await deviceAction('rotate-token', { deviceId, role }) setActionLoading(null) if (!result.ok) { setActionError(result.error || 'Failed to rotate token') } else { onRefresh() } } async function handleRevokeToken(deviceId: string, role?: string) { setActionError(null) setActionLoading(`revoke-${deviceId}`) const result = await deviceAction('revoke-token', { deviceId, role }) setActionLoading(null) setConfirmRevoke(null) if (!result.ok) { setActionError(result.error || 'Failed to revoke token') } else { onRefresh() } } if (devices.length === 0) { return (
{t('noPairedDevices')}
) } return (

{t('pairedDevices', { count: devices.length })}

{actionError && (
{actionError}
)}
{devices.map((device) => { const deviceKey = device.deviceId || device.id const isExpanded = expandedDevice === deviceKey const tokens = device.tokens || [] return ( ) })}
{t('colName')} {t('colDeviceId')} {t('colRoles')} {t('colPaired')} {t('colLastSeen')} {t('colTrust')} {t('colActions')}
{device.displayName} {(device.deviceId || device.id)?.slice(0, 12)}...
{(device.roles || []).map((role) => ( {role} ))}
{relativeTime(device.pairedAt || device.approvedAtMs || device.createdAtMs || 0)} {device.lastSeen ? relativeTime(device.lastSeen) : '--'} {device.trusted ? ( {t('trusted')} ) : ( {t('untrusted')} )}
{confirmRevoke === deviceKey ? (
{t('revokeConfirm')}
) : ( )} {tokens.length > 0 && ( )}
{isExpanded && tokens.length > 0 && (
{tokens.map((token, i) => (
{token.role} {token.scopes && token.scopes.length > 0 && ( [{token.scopes.join(', ')}] )} {token.lastUsedAtMs && ( {t('tokenUsed', { time: relativeTime(token.lastUsedAtMs) })} )} {token.revokedAtMs && ( {t('revoked')} )}
))}
)}
) }