connect tailscale node status through platform gateway
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 17:13:42 +09:00
parent 06137f3d77
commit 90a22c098c
2 changed files with 333 additions and 62 deletions
+271 -27
View File
@@ -11,7 +11,6 @@ import {
launcherCommonSettings,
launcherVersions,
runtimeCommands,
tailscaleNodes as mockTailscaleNodes,
tailscaleSettings
} from '../data/mock';
import { apiRequest } from '../lib/api';
@@ -50,6 +49,45 @@ type TailscaleNodesResponse = {
error?: string;
};
type TailscaleRoute = {
id: string;
nodeId: string;
nodeName: string;
user: string;
route: string;
approved: boolean;
advertised: boolean;
connected: boolean;
lastSeenAt: string | null;
};
type TailscaleRoutesResponse = {
routes: TailscaleRoute[];
source?: string;
fetchedAt?: string;
error?: string;
};
type TailscaleAcl = {
id: string;
action: string;
sources: string[];
destinations: string[];
};
type TailscaleAclPolicy = {
groups: Record<string, string[]>;
tagOwners: Record<string, string[]>;
acls: TailscaleAcl[];
};
type TailscaleAclResponse = {
policy: TailscaleAclPolicy;
source?: string;
fetchedAt?: string;
error?: string;
};
function formatTailscaleLastSeen(value: string | null) {
if (!value) return '-';
const time = Date.parse(value);
@@ -448,9 +486,23 @@ export function TailscaleStatusPage() {
}
function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
const pageSize = 10;
const [page, setPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageNodes = nodes.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [nodes]);
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader title="노드 현황" description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다." />
<CardHeader
title="노드 현황"
description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다."
action={<span className="text-xs font-semibold text-muted-foreground">총 {nodes.length}건 · {currentPage}/{totalPages}페이지</span>}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
@@ -475,7 +527,7 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
</tr>
) : null}
{nodes.map((node) => (
{pageNodes.map((node) => (
<tr className="hover:bg-muted/20" key={node.name}>
<td className="px-5 py-3 font-medium">{node.name}</td>
<td className="px-5 py-3">{node.ip}</td>
@@ -489,27 +541,111 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-3 text-xs text-muted-foreground">
<span>
{nodes.length === 0 ? '0건' : `${(currentPage - 1) * pageSize + 1}-${Math.min(currentPage * pageSize, nodes.length)} / ${nodes.length}건`}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="h-8 px-3 text-[13px]"
disabled={currentPage <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
>
이전
</Button>
<Button
variant="outline"
className="h-8 px-3 text-[13px]"
disabled={currentPage >= totalPages}
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
>
다음
</Button>
</div>
</div>
</Card>
);
}
export function TailscaleRoutesPage() {
const routes = mockTailscaleNodes.filter((node) => node.routes !== '-');
const [routes, setRoutes] = useState<TailscaleRoute[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const refresh = () => {
setLoading(true);
setError('');
apiRequest<TailscaleRoutesResponse>('/tailscale/routes')
.then((result) => {
setRoutes(result.routes ?? []);
setError(result.error ?? '');
})
.catch((requestError: Error) => setError(requestError.message))
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
}, []);
return (
<>
<PageTitle eyebrow="테일스케일" title="라우트" description="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." />
<PageTitle
eyebrow="테일스케일"
title="라우트"
description="tailnet 노드가 광고하거나 승인받은 네트워크 경로입니다."
actions={
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RefreshCcw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
새로고침
</Button>
}
/>
{error ? (
<Card className="mb-4 border-amber-300/40 bg-amber-500/5 px-5 py-3 text-sm text-amber-700">
{error}
</Card>
) : null}
<Card>
<CardHeader title="라우트 광고" description="승인된 subnet route와 노드 상태입니다." />
<div className="divide-y divide-border">
{routes.map((route) => (
<div className="flex items-center justify-between gap-4 px-5 py-3" key={route.name}>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{route.routes}</p>
<p className="mt-1 text-xs text-muted-foreground">{route.name} · {route.ip}</p>
</div>
<StatusBadge status={route.status} />
</div>
))}
<CardHeader title="라우트 목록" description="현재 Headscale 노드 기준 라우트 목록입니다." action={<span className="text-xs font-semibold text-muted-foreground">총 {routes.length}건</span>} />
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">Route</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">광고</th>
<th className="px-5 py-3 font-medium">승인</th>
<th className="px-5 py-3 font-medium">Last Seen</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && routes.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">라우트 정보를 불러오는 중입니다.</td>
</tr>
) : null}
{!loading && routes.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 라우트가 없습니다.</td>
</tr>
) : null}
{routes.map((route) => (
<tr className="hover:bg-muted/20" key={route.id}>
<td className="px-5 py-3 font-mono text-xs font-semibold">{route.route}</td>
<td className="px-5 py-3 font-medium">{route.nodeName}</td>
<td className="px-5 py-3 text-muted-foreground">{route.user || '-'}</td>
<td className="px-5 py-3"><StatusBadge status={route.advertised ? '활성' : '비활성'} /></td>
<td className="px-5 py-3"><StatusBadge status={route.approved ? '승인됨' : '대기'} badgeTone={route.approved ? 'green' : 'amber'} /></td>
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(route.lastSeenAt)}</td>
<td className="px-5 py-3"><StatusBadge status={route.connected ? '활성' : '오프라인'} /></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
@@ -517,28 +653,136 @@ export function TailscaleRoutesPage() {
}
export function TailscaleAclPage() {
const emptyPolicy: TailscaleAclPolicy = { groups: {}, tagOwners: {}, acls: [] };
const [policy, setPolicy] = useState<TailscaleAclPolicy>(emptyPolicy);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const groups = Object.entries(policy.groups ?? {});
const tagOwners = Object.entries(policy.tagOwners ?? {});
const refresh = () => {
setLoading(true);
setError('');
apiRequest<TailscaleAclResponse>('/tailscale/acl')
.then((result) => {
setPolicy(result.policy ?? emptyPolicy);
setError(result.error ?? '');
})
.catch((requestError: Error) => setError(requestError.message))
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
}, []);
return (
<>
<PageTitle eyebrow="테일스케일" title="ACL" description="사용자 그룹과 장비 태그 기준의 tailnet 접근 정책을 관리합니다." />
<div className="grid gap-4 xl:grid-cols-3">
<PageTitle
eyebrow="테일스케일"
title="ACL"
description="Headscale에 적용된 tailnet 접근 정책입니다."
actions={
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RefreshCcw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
새로고침
</Button>
}
/>
{error ? (
<Card className="mb-4 border-amber-300/40 bg-amber-500/5 px-5 py-3 text-sm text-amber-700">
{error}
</Card>
) : null}
<div className="grid gap-4 md:grid-cols-3">
{[
['강사 장비 접근', 'group:instructors -> tag:teaching-device', '활성'],
['학생 워크스페이스', 'group:students -> tag:assigned-workspace', '활성'],
['운영자 전체 접근', 'group:ops -> tag:platform-admin', '활성']
].map(([title, description, status]) => (
['ACL', `${policy.acls.length}개`, '접근 허용 규칙'],
['Groups', `${groups.length}개`, '사용자 그룹'],
['Tag Owners', `${tagOwners.length}개`, '태그 소유자']
].map(([title, value, description]) => (
<Card className="p-5" key={title}>
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-semibold">{title}</p>
<StatusBadge status={status} />
</div>
<p className="mt-3 text-xs text-muted-foreground">{description}</p>
<p className="text-sm text-muted-foreground">{title}</p>
<p className="mt-3 text-2xl font-semibold">{value}</p>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</Card>
))}
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-[1.4fr_1fr]">
<Card className="min-w-0 overflow-hidden">
<CardHeader title="ACL 목록" description="Source에서 Destination으로 허용된 접근 규칙입니다." action={<span className="text-xs font-semibold text-muted-foreground">총 {policy.acls.length}건</span>} />
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">Action</th>
<th className="px-5 py-3 font-medium">Source</th>
<th className="px-5 py-3 font-medium">Destination</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && policy.acls.length === 0 ? (
<tr>
<td colSpan={3} className="px-5 py-10 text-center text-muted-foreground">ACL 정보를 불러오는 중입니다.</td>
</tr>
) : null}
{!loading && policy.acls.length === 0 ? (
<tr>
<td colSpan={3} className="px-5 py-10 text-center text-muted-foreground">표시할 ACL이 없습니다.</td>
</tr>
) : null}
{policy.acls.map((acl) => (
<tr className="hover:bg-muted/20" key={acl.id}>
<td className="px-5 py-4"><StatusBadge status={acl.action === 'accept' ? '허용' : acl.action} badgeTone={acl.action === 'accept' ? 'green' : 'neutral'} /></td>
<td className="px-5 py-4">
<div className="flex flex-wrap gap-1.5">
{acl.sources.length ? acl.sources.map((source) => <Badge tone="blue" key={source}>{source}</Badge>) : <span className="text-muted-foreground">-</span>}
</div>
</td>
<td className="px-5 py-4">
<div className="flex flex-wrap gap-1.5">
{acl.destinations.length ? acl.destinations.map((destination) => <Badge tone="violet" key={destination}>{destination}</Badge>) : <span className="text-muted-foreground">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="grid min-w-0 gap-4">
<PolicyMapCard title="Groups" description="Headscale policy의 사용자 그룹입니다." entries={groups} emptyText="등록된 그룹이 없습니다." />
<PolicyMapCard title="Tag Owners" description="태그를 부여할 수 있는 소유자입니다." entries={tagOwners} emptyText="등록된 태그 소유자가 없습니다." />
</div>
</div>
</>
);
}
function PolicyMapCard({ title, description, entries, emptyText }: { title: string; description: string; entries: [string, string[]][]; emptyText: string }) {
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader title={title} description={description} action={<span className="text-xs font-semibold text-muted-foreground">총 {entries.length}건</span>} />
<div className="max-h-[320px] overflow-y-auto">
{entries.length === 0 ? (
<div className="px-5 py-8 text-center text-sm text-muted-foreground">{emptyText}</div>
) : (
<div className="divide-y divide-border">
{entries.map(([key, values]) => (
<div className="px-5 py-4" key={key}>
<p className="break-all font-mono text-xs font-semibold">{key}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{values.length ? values.map((value) => <Badge tone="neutral" key={value}>{value}</Badge>) : <span className="text-xs text-muted-foreground">-</span>}
</div>
</div>
))}
</div>
)}
</div>
</Card>
);
}
export function TailscaleDiagnosticsPage() {
return (
<>