improve runtime environment variable modal usability
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,86 +1,159 @@
|
||||
import { Activity, MoreHorizontal, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
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 { agentDevices } from '../../data/mock';
|
||||
import { loadPlatformDevices, type PlatformDevice } from '../agents/platform-api';
|
||||
|
||||
function getTone(status: string) {
|
||||
if (['정상', '활성', '성공', '운영', 'Running'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '대기', '준비중', '카나리', 'Pending'].includes(status)) return 'amber';
|
||||
if (['오류', '실패', '비활성', '만료', 'Error', 'Failed'].includes(status)) return 'red';
|
||||
if (['검수', '동기화'].includes(status)) return 'blue';
|
||||
return 'neutral';
|
||||
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[] };
|
||||
|
||||
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))];
|
||||
}
|
||||
|
||||
export function RuntimeDeviceControlPage() {
|
||||
const [rows, setRows] = useState<PlatformRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<PlatformRow | null>(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 ?? [];
|
||||
setRows((nodeResponse.nodes ?? []).map((node) => ({
|
||||
...node,
|
||||
device: findDevice(node, devices),
|
||||
controllers: findControllers(node, rules),
|
||||
})));
|
||||
})
|
||||
.catch(() => setRows([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<PlatformRow>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'platform',
|
||||
header: '플랫폼',
|
||||
flexible: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold" title={row.name}>{row.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.user}>{row.user || '-'}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'connection',
|
||||
header: '연결 대상',
|
||||
width: 180,
|
||||
cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.ip || '-'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'controllers',
|
||||
header: '제어 사용자',
|
||||
width: 280,
|
||||
cell: (row) => (
|
||||
<span className="block truncate text-muted-foreground" title={row.controllers.length ? row.controllers.join(', ') : row.user || '-'}>
|
||||
{row.controllers.length ? row.controllers.join(', ') : row.user || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'routes', header: '연결 경로', width: 190, cell: (row) => <span className="block truncate text-muted-foreground" title={row.routes || '-'}>{row.routes || '-'}</span> },
|
||||
{ key: 'status', header: '상태', width: 110, cell: (row) => <Badge tone={row.online || row.status === '활성' ? 'green' : 'neutral'}>{row.online || row.status === '활성' ? '연결됨' : '오프라인'}</Badge> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" className="size-8 px-0" title="상세" aria-label="상세" onClick={() => setSelected(row)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title="장비 목록"
|
||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
상태 필터
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<Activity className="size-4" />
|
||||
상태 갱신
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="border-b border-border px-5 pb-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="장비명, 계정, 워크스페이스 검색"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">장비</th>
|
||||
<th className="px-5 py-3 font-medium">계정</th>
|
||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
<th className="px-5 py-3 text-right font-medium">명령</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{agentDevices.map((device) => (
|
||||
<tr key={device.name} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||
<td className="px-5 py-3">{device.sshKey}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={getTone(device.status)}>{device.status}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||
<Button variant="ghost" className="size-8 px-0">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<PageTitle
|
||||
eyebrow="플랫폼 관리"
|
||||
title="연결 제어"
|
||||
description="Headscale 노드 연결 상태와 ACL 기반 제어 권한을 확인합니다."
|
||||
actions={
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||
새로고침
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataTableCard
|
||||
title="연결 플랫폼"
|
||||
description="Headscale에 등록된 노드와 해당 노드에 적용된 ACL 제어 사용자를 표시합니다."
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
searchPlaceholder="플랫폼, 사용자, IP, ACL 검색"
|
||||
getSearchText={(row) => `${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에 연결된 플랫폼이 없습니다."
|
||||
/>
|
||||
<Modal open={Boolean(selected)} title="플랫폼 연결 상세" onClose={() => setSelected(null)}>
|
||||
<dl className="grid grid-cols-[140px_minmax(0,1fr)] gap-x-4 gap-y-4 text-sm">
|
||||
<dt className="text-muted-foreground">플랫폼</dt><dd className="min-w-0 break-all font-medium">{selected?.name}</dd>
|
||||
<dt className="text-muted-foreground">Headscale 사용자</dt><dd className="min-w-0 break-all">{selected?.user || '-'}</dd>
|
||||
<dt className="text-muted-foreground">연결 IP</dt><dd className="font-mono text-xs">{selected?.ips?.join(', ') || selected?.ip || '-'}</dd>
|
||||
<dt className="text-muted-foreground">ACL 제어 사용자</dt><dd className="min-w-0 break-all">{selected?.controllers.length ? selected.controllers.join(', ') : '개별 ACL 규칙 없음'}</dd>
|
||||
<dt className="text-muted-foreground">연결 경로</dt><dd className="min-w-0 break-all">{selected?.routes || '-'}</dd>
|
||||
<dt className="text-muted-foreground">Workspace</dt><dd className="min-w-0 break-all">{selected?.device?.workspaceRoot || '-'}</dd>
|
||||
<dt className="text-muted-foreground">SSH 사용자</dt><dd>{selected?.device?.sshUser || '-'}</dd>
|
||||
<dt className="text-muted-foreground">사용 유형</dt><dd>{selected?.device?.usageType || '-'}</dd>
|
||||
</dl>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user