@@ -1,4 +1,4 @@
|
|||||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2 } from 'lucide-react';
|
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, Trash2 } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
||||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
||||||
@@ -331,6 +331,8 @@ export function TailscaleStatusPage() {
|
|||||||
const [source, setSource] = useState('');
|
const [source, setSource] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<TailscaleNode | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
|
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
|
||||||
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
||||||
|
|
||||||
@@ -353,6 +355,20 @@ export function TailscaleStatusPage() {
|
|||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const deleteNode = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
setError('');
|
||||||
|
apiRequest<{ deleted: boolean }>(`/tailscale/nodes/${encodeURIComponent(deleteTarget.id)}`, { method: 'DELETE' })
|
||||||
|
.then(() => {
|
||||||
|
setNodes((current) => current.filter((node) => node.id !== deleteTarget.id));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((requestError: Error) => setError(requestError.message))
|
||||||
|
.finally(() => setDeleting(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -385,13 +401,30 @@ export function TailscaleStatusPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TailscaleNodeTable nodes={nodes} loading={loading} />
|
<TailscaleNodeTable nodes={nodes} loading={loading} onDelete={setDeleteTarget} />
|
||||||
</div>
|
</div>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="노드 삭제"
|
||||||
|
onClose={() => !deleting && setDeleteTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>취소</Button>
|
||||||
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deleting} onClick={deleteNode}>
|
||||||
|
{deleting ? '삭제 중...' : '삭제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<strong className="text-foreground">{deleteTarget?.name}</strong> 노드를 Headscale에서 삭제합니다. 연결된 기기의 VPN 연결은 즉시 끊기며, 다시 사용하려면 재등록해야 합니다.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
|
function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode[]; loading: boolean; onDelete: (node: TailscaleNode) => void }) {
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
|
||||||
@@ -420,17 +453,18 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin
|
|||||||
<th className="px-5 py-3 font-medium">Routes</th>
|
<th className="px-5 py-3 font-medium">Routes</th>
|
||||||
<th className="px-5 py-3 font-medium">Last Seen</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 font-medium">상태</th>
|
||||||
|
<th className="px-5 py-3 font-medium"><span className="sr-only">관리</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{loading && nodes.length === 0 ? (
|
{loading && nodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
|
<td colSpan={8} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && nodes.length === 0 ? (
|
{!loading && nodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
<td colSpan={8} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{pageNodes.map((node) => (
|
{pageNodes.map((node) => (
|
||||||
@@ -442,6 +476,17 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin
|
|||||||
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
||||||
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
||||||
|
<td className="px-5 py-3 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 p-0 text-muted-foreground hover:text-rose-600"
|
||||||
|
title="노드 삭제"
|
||||||
|
aria-label={`${node.name} 노드 삭제`}
|
||||||
|
onClick={() => onDelete(node)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user