import { MoreHorizontal, RotateCcw } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { DataTableCard, type DataTableColumn } from '../../components/data-table'; import { Badge, Button, Modal } from '../../components/ui'; import { apiRequest } from '../../lib/api'; import { PageTitle } from '../../layout/AppLayout'; import { loadPlatformDevices, type PlatformDevice } from '../agents/platform-api'; type HeadscaleNode = { id: string; name: string; ip: string; ips?: string[]; role: string; user: string; routes: string; lastSeen: string | null; status: string; online?: boolean; }; type AclRule = { id: string; action: string; sources: string[]; destinations: string[] }; type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[]; no: number }; type DeviceKind = 'agent' | 'launcher' | 'unknown'; function normalize(value?: string | null) { return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, ''); } function findDevice(node: HeadscaleNode, devices: PlatformDevice[]) { const nodeValues = [node.name, node.user].map(normalize).filter(Boolean); return devices.find((device) => { const deviceValues = [device.hostname, device.tailscaleHostname, device.accountEmail].map(normalize).filter(Boolean); return nodeValues.some((nodeValue) => deviceValues.some((deviceValue) => nodeValue.includes(deviceValue) || deviceValue.includes(nodeValue))); }); } function findControllers(node: HeadscaleNode, rules: AclRule[]) { const destinations = new Set([node.ip, ...(node.ips ?? [])].filter(Boolean).map((ip) => `${ip}:*`)); return [...new Set(rules .filter((rule) => rule.action === 'accept' && rule.destinations.some((destination) => destinations.has(destination))) .flatMap((rule) => rule.sources))]; } function getDeviceKind(row: PlatformRow): DeviceKind { if (!row.device) return 'unknown'; return row.device.agentVersion || row.device.agentLastSeenAt ? 'agent' : 'launcher'; } function formatDateTime(value?: string | null) { if (!value) return '-'; const date = new Date(value); return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('ko-KR', { hour12: false }); } export function RuntimeDeviceControlPage() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [selected, setSelected] = useState(null); const refresh = useCallback(() => { setLoading(true); Promise.all([ apiRequest<{ nodes?: HeadscaleNode[] }>('/tailscale/nodes'), apiRequest<{ policy?: { acls?: AclRule[] } }>('/tailscale/acl'), loadPlatformDevices(), ]) .then(([nodeResponse, aclResponse, devices]) => { const rules = aclResponse.policy?.acls ?? []; const nodes = nodeResponse.nodes ?? []; setRows(nodes.map((node, index) => ({ ...node, no: nodes.length - index, device: findDevice(node, devices), controllers: findControllers(node, rules), }))); }) .catch(() => setRows([])) .finally(() => setLoading(false)); }, []); useEffect(() => { void refresh(); }, [refresh]); const columns = useMemo>>( () => [ { key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => {row.no} }, { key: 'platform', header: '플랫폼', flexible: true, cell: (row) => (

{row.name}

{row.user || '-'}

) }, { key: 'connection', header: '연결 대상', width: 180, cell: (row) => {row.ip || '-'}, }, { key: 'deviceKind', header: '유형', width: 150, cell: (row) => { const kind = getDeviceKind(row); if (kind === 'agent') { return 에이전트; } if (kind === 'launcher') return 런처 클라이언트; return 미확인; }, }, { key: 'controllers', header: '제어 사용자', width: 280, cell: (row) => ( {row.controllers.length ? row.controllers.join(', ') : row.user || '-'} ), }, { key: 'routes', header: '연결 경로', width: 190, cell: (row) => {row.routes || '-'} }, { key: 'status', header: '상태', width: 110, cell: (row) => {row.online || row.status === '활성' ? '연결됨' : '오프라인'} }, { key: 'actions', header: '', className: 'w-20 px-5 py-3 text-right font-medium', cell: (row) => (
), }, ], [] ); return ( <> 새로고침 } /> `${row.name} ${row.user} ${row.ip} ${row.controllers.join(' ')} ${row.routes}`} getRowKey={(row) => row.id || row.name} pageSize={6} minWidth={1100} loading={loading} emptyMessage="Headscale에 연결된 플랫폼이 없습니다." /> setSelected(null)}>
플랫폼
{selected?.name}
Headscale 사용자
{selected?.user || '-'}
연결 IP
{selected?.ips?.join(', ') || selected?.ip || '-'}
ACL 제어 사용자
{selected?.controllers.length ? selected.controllers.join(', ') : '개별 ACL 규칙 없음'}
연결 경로
{selected?.routes || '-'}
Workspace
{selected?.device?.workspaceRoot || '-'}
SSH 사용자
{selected?.device?.sshUser || '-'}
사용 유형
{selected?.device?.usageType || '-'}
장비 유형
{selected ? ({ agent: '에이전트', launcher: '런처 클라이언트', unknown: '미확인' }[getDeviceKind(selected)]) : '-'}
에이전트 버전
{selected?.device?.agentVersion || '-'}
마지막 에이전트 확인
{formatDateTime(selected?.device?.agentLastSeenAt)}
); }