nyk
feat: full i18n — 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified
Raw
History Blame Contribute Delete
22.6 kB
'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<string, unknown>,
): Promise<{ ok: boolean; error?: string; data?: Record<string, unknown> }> {
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<Tab>('instances')
const [nodes, setNodes] = useState<PresenceEntry[]>([])
const [devices, setDevices] = useState<PairedDevice[]>([])
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const [connected, setConnected] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="m-4">
<div className="flex items-center gap-3 mb-4">
<h2 className="text-lg font-semibold text-foreground">{t('title')}</h2>
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs font-medium border ${
connected
? 'bg-green-500/10 text-green-400 border-green-500/30'
: 'bg-red-500/10 text-red-400 border-red-500/30'
}`}
>
{connected ? t('gatewayConnected') : t('gatewayUnreachable')}
</span>
</div>
{/* Tab bar */}
<div className="flex gap-1 mb-4">
<Button
variant={tab === 'instances' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setTab('instances')}
>
{t('tabInstances', { count: nodes.length })}
</Button>
<Button
variant={tab === 'devices' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setTab('devices')}
>
{t('tabDevices', { count: totalDeviceCount })}
{pendingCount > 0 && (
<span className="ml-1.5 px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">
{pendingCount}
</span>
)}
</Button>
</div>
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
{loading ? (
<div className="text-muted-foreground text-sm py-8 text-center">{t('loading')}</div>
) : tab === 'instances' ? (
<InstancesTab nodes={nodes} />
) : (
<DevicesTab
devices={devices}
pendingDevices={pendingDevices}
onRefresh={fetchDevices}
/>
)}
</div>
)
}
function InstancesTab({ nodes }: { nodes: PresenceEntry[] }) {
const t = useTranslations('nodes')
if (nodes.length === 0) {
return (
<div className="text-muted-foreground text-sm py-8 text-center">
{t('noInstances')}
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">{t('colName')}</th>
<th className="pb-2 pr-4 font-medium">{t('colClientId')}</th>
<th className="pb-2 pr-4 font-medium">{t('colPlatform')}</th>
<th className="pb-2 pr-4 font-medium">{t('colVersion')}</th>
<th className="pb-2 pr-4 font-medium">{t('colRoles')}</th>
<th className="pb-2 pr-4 font-medium">{t('colStatus')}</th>
<th className="pb-2 pr-4 font-medium">{t('colConnected')}</th>
<th className="pb-2 pr-4 font-medium">{t('colLastActivity')}</th>
<th className="pb-2 font-medium">{t('colHostIp')}</th>
</tr>
</thead>
<tbody>
{nodes.map((node) => (
<tr key={node.id} className="border-b border-border/50">
<td className="py-2 pr-4 text-foreground font-medium">{node.displayName}</td>
<td className="py-2 pr-4 text-muted-foreground font-mono text-xs">
{node.clientId?.slice(0, 12)}...
</td>
<td className="py-2 pr-4 text-muted-foreground">{node.platform}</td>
<td className="py-2 pr-4 text-muted-foreground">{node.version}</td>
<td className="py-2 pr-4">
<div className="flex gap-1 flex-wrap">
{(node.roles || []).map((role) => (
<span
key={role}
className="px-1.5 py-0.5 rounded text-xs bg-secondary text-muted-foreground"
>
{role}
</span>
))}
</div>
</td>
<td className="py-2 pr-4">
<span
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium border ${statusColor(node.status)}`}
>
{node.status}
</span>
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">
{relativeTime(node.connectedAt)}
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">
{relativeTime(node.lastActivity)}
</td>
<td className="py-2 text-muted-foreground text-xs font-mono">
{node.host || node.ip || '--'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function DevicesTab({
devices,
pendingDevices,
onRefresh,
}: {
devices: PairedDevice[]
pendingDevices: PendingDevice[]
onRefresh: () => void
}) {
return (
<div className="space-y-6">
{pendingDevices.length > 0 && (
<PendingDevicesSection devices={pendingDevices} onRefresh={onRefresh} />
)}
<PairedDevicesSection devices={devices} onRefresh={onRefresh} />
</div>
)
}
function PendingDevicesSection({
devices,
onRefresh,
}: {
devices: PendingDevice[]
onRefresh: () => void
}) {
const t = useTranslations('nodes')
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(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 (
<div>
<h3 className="text-sm font-medium text-amber-400 mb-2">
{t('pendingPairingRequests', { count: devices.length })}
</h3>
{actionError && (
<div className="mb-2 px-3 py-1.5 rounded bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
{actionError}
</div>
)}
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.requestId}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-amber-500/5 border border-amber-500/20"
>
<div className="flex items-center gap-3">
<div>
<span className="text-sm font-medium text-foreground">
{device.displayName || device.deviceId}
</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{device.deviceId?.slice(0, 16)}</span>
{device.role && (
<span className="px-1.5 py-0.5 rounded bg-secondary text-muted-foreground">
{device.role}
</span>
)}
{device.remoteIp && <span>{device.remoteIp}</span>}
{device.isRepair && (
<span className="px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 border border-blue-500/30">
repair
</span>
)}
{device.ts && <span>{relativeTime(device.ts)}</span>}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
disabled={actionLoading !== null}
onClick={() => handleAction('approve', device.requestId)}
>
{actionLoading === `approve-${device.requestId}` ? t('approving') : t('approve')}
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
disabled={actionLoading !== null}
onClick={() => handleAction('reject', device.requestId)}
>
{actionLoading === `reject-${device.requestId}` ? t('rejecting') : t('reject')}
</Button>
</div>
</div>
))}
</div>
</div>
)
}
function PairedDevicesSection({
devices,
onRefresh,
}: {
devices: PairedDevice[]
onRefresh: () => void
}) {
const t = useTranslations('nodes')
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null)
const [expandedDevice, setExpandedDevice] = useState<string | null>(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 (
<div className="text-muted-foreground text-sm py-8 text-center">
{t('noPairedDevices')}
</div>
)
}
return (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
{t('pairedDevices', { count: devices.length })}
</h3>
{actionError && (
<div className="mb-2 px-3 py-1.5 rounded bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
{actionError}
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">{t('colName')}</th>
<th className="pb-2 pr-4 font-medium">{t('colDeviceId')}</th>
<th className="pb-2 pr-4 font-medium">{t('colRoles')}</th>
<th className="pb-2 pr-4 font-medium">{t('colPaired')}</th>
<th className="pb-2 pr-4 font-medium">{t('colLastSeen')}</th>
<th className="pb-2 pr-4 font-medium">{t('colTrust')}</th>
<th className="pb-2 font-medium">{t('colActions')}</th>
</tr>
</thead>
<tbody>
{devices.map((device) => {
const deviceKey = device.deviceId || device.id
const isExpanded = expandedDevice === deviceKey
const tokens = device.tokens || []
return (
<tr key={device.id || device.deviceId} className="border-b border-border/50 align-top">
<td className="py-2 pr-4 text-foreground font-medium">
{device.displayName}
</td>
<td className="py-2 pr-4 text-muted-foreground font-mono text-xs">
{(device.deviceId || device.id)?.slice(0, 12)}...
</td>
<td className="py-2 pr-4">
<div className="flex gap-1 flex-wrap">
{(device.roles || []).map((role) => (
<span
key={role}
className="px-1.5 py-0.5 rounded text-xs bg-secondary text-muted-foreground"
>
{role}
</span>
))}
</div>
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">
{relativeTime(device.pairedAt || device.approvedAtMs || device.createdAtMs || 0)}
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">
{device.lastSeen ? relativeTime(device.lastSeen) : '--'}
</td>
<td className="py-2 pr-4">
{device.trusted ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs font-medium border bg-green-500/20 text-green-400 border-green-500/30">
{t('trusted')}
</span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs font-medium border bg-zinc-500/20 text-zinc-400 border-zinc-500/30">
{t('untrusted')}
</span>
)}
</td>
<td className="py-2">
<div className="flex flex-col gap-1">
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={actionLoading !== null}
onClick={() => handleRotateToken(deviceKey)}
>
{actionLoading === `rotate-${deviceKey}` ? '...' : t('rotateToken')}
</Button>
{confirmRevoke === deviceKey ? (
<div className="flex gap-1 items-center">
<span className="text-xs text-red-400">{t('revokeConfirm')}</span>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-red-400 hover:bg-red-500/10"
disabled={actionLoading !== null}
onClick={() => handleRevokeToken(deviceKey)}
>
{actionLoading === `revoke-${deviceKey}` ? '...' : t('yes')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={() => setConfirmRevoke(null)}
>
{t('no')}
</Button>
</div>
) : (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-red-400 hover:bg-red-500/10"
disabled={actionLoading !== null}
onClick={() => setConfirmRevoke(deviceKey)}
>
{t('revoke')}
</Button>
)}
{tokens.length > 0 && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setExpandedDevice(isExpanded ? null : deviceKey)}
>
{isExpanded ? t('hideTokens') : t('tokens', { count: tokens.length })}
</Button>
)}
</div>
{isExpanded && tokens.length > 0 && (
<div className="mt-1 space-y-1">
{tokens.map((token, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1 rounded bg-secondary/50 text-xs"
>
<span className="font-medium text-foreground">{token.role}</span>
{token.scopes && token.scopes.length > 0 && (
<span className="text-muted-foreground">
[{token.scopes.join(', ')}]
</span>
)}
{token.lastUsedAtMs && (
<span className="text-muted-foreground">
{t('tokenUsed', { time: relativeTime(token.lastUsedAtMs) })}
</span>
)}
{token.revokedAtMs && (
<span className="text-red-400">{t('revoked')}</span>
)}
<div className="flex gap-1 ml-auto">
<Button
size="sm"
variant="ghost"
className="h-5 px-1.5 text-[10px]"
disabled={actionLoading !== null}
onClick={() => handleRotateToken(deviceKey, token.role)}
>
{t('rotate')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-5 px-1.5 text-[10px] text-red-400 hover:bg-red-500/10"
disabled={actionLoading !== null || !!token.revokedAtMs}
onClick={() => handleRevokeToken(deviceKey, token.role)}
>
{t('revoke')}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}