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

This commit is contained in:
2026-07-08 16:39:09 +09:00
parent 01258b042b
commit 06137f3d77
+84 -14
View File
@@ -1,5 +1,5 @@
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-react'; import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-react';
import { useMemo, useState } from 'react'; import { 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';
import { import {
@@ -11,9 +11,10 @@ import {
launcherCommonSettings, launcherCommonSettings,
launcherVersions, launcherVersions,
runtimeCommands, runtimeCommands,
tailscaleNodes, tailscaleNodes as mockTailscaleNodes,
tailscaleSettings tailscaleSettings
} from '../data/mock'; } from '../data/mock';
import { apiRequest } from '../lib/api';
import { PageTitle } from '../layout/AppLayout'; import { PageTitle } from '../layout/AppLayout';
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet'; type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
@@ -29,6 +30,37 @@ function StatusBadge({ status, badgeTone }: { status: string; badgeTone?: Tone }
return <Badge tone={badgeTone ?? tone(status)}>{status}</Badge>; return <Badge tone={badgeTone ?? tone(status)}>{status}</Badge>;
} }
type TailscaleNode = {
id: string;
name: string;
ip: string;
ips?: string[];
role: string;
user: string;
routes: string;
lastSeen: string | null;
status: string;
online?: boolean;
};
type TailscaleNodesResponse = {
nodes: TailscaleNode[];
source?: string;
fetchedAt?: string;
error?: string;
};
function formatTailscaleLastSeen(value: string | null) {
if (!value) return '-';
const time = Date.parse(value);
if (!Number.isFinite(time)) return value;
const diff = Date.now() - time;
if (diff < 60_000) return '방금 전';
if (diff < 60 * 60_000) return `${Math.floor(diff / 60_000)}분 전`;
if (diff < 24 * 60 * 60_000) return `${Math.floor(diff / (60 * 60_000))}시간 전`;
return new Date(time).toLocaleString();
}
export function LauncherVersionsPage() { export function LauncherVersionsPage() {
const [sourceOpen, setSourceOpen] = useState(false); const [sourceOpen, setSourceOpen] = useState(false);
const [releaseOpen, setReleaseOpen] = useState(false); const [releaseOpen, setReleaseOpen] = useState(false);
@@ -351,25 +383,48 @@ export function LauncherCommonSettingsPage() {
} }
export function TailscaleStatusPage() { export function TailscaleStatusPage() {
const online = tailscaleNodes.filter((node) => node.status === '활성').length; const [nodes, setNodes] = useState<TailscaleNode[]>([]);
const subnetRouters = tailscaleNodes.filter((node) => node.routes !== '-').length; const [source, setSource] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
const refresh = () => {
setLoading(true);
setError('');
apiRequest<TailscaleNodesResponse>('/tailscale/nodes')
.then((result) => {
setNodes(result.nodes ?? []);
setSource(result.source ?? '');
setError(result.error ?? '');
})
.catch((requestError: Error) => {
setError(requestError.message);
})
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
}, []);
return ( return (
<> <>
<PageTitle <PageTitle
eyebrow="테일스케일" eyebrow="테일스케일"
title="노드 현황" title="노드 현황"
description="플랫폼 게이트웨이가 수집한 tailnet 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다." description="tailnet 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다."
actions={ actions={
<Button className="h-8 px-3 text-[13px]"> <Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RefreshCcw className="size-4" /> <RefreshCcw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
상태 갱신 새로고침
</Button> </Button>
} }
/> />
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
{[ {[
['등록 노드', `${tailscaleNodes.length}대`, '게이트웨이 수집 기준'], ['등록 노드', `${nodes.length}대`, source ? `${source} 수집 기준` : '게이트웨이 수집 기준'],
['온라인', `${online}대`, '최근 접속 정상'], ['온라인', `${online}대`, '최근 접속 정상'],
['Subnet Router', `${subnetRouters}대`, '클러스터 CIDR 광고'] ['Subnet Router', `${subnetRouters}대`, '클러스터 CIDR 광고']
].map(([label, value, description]) => ( ].map(([label, value, description]) => (
@@ -380,14 +435,19 @@ export function TailscaleStatusPage() {
</Card> </Card>
))} ))}
</div> </div>
{error ? (
<Card className="mt-4 border-amber-300/40 bg-amber-500/5 px-5 py-3 text-sm text-amber-700">
{error}
</Card>
) : null}
<div className="mt-4"> <div className="mt-4">
<TailscaleNodeTable /> <TailscaleNodeTable nodes={nodes} loading={loading} />
</div> </div>
</> </>
); );
} }
function TailscaleNodeTable() { function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
return ( return (
<Card className="min-w-0 overflow-hidden"> <Card className="min-w-0 overflow-hidden">
<CardHeader title="노드 현황" description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다." /> <CardHeader title="노드 현황" description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다." />
@@ -405,14 +465,24 @@ function TailscaleNodeTable() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{tailscaleNodes.map((node) => ( {loading && nodes.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
</tr>
) : null}
{!loading && nodes.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
</tr>
) : null}
{nodes.map((node) => (
<tr className="hover:bg-muted/20" key={node.name}> <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 font-medium">{node.name}</td>
<td className="px-5 py-3">{node.ip}</td> <td className="px-5 py-3">{node.ip}</td>
<td className="px-5 py-3 text-muted-foreground">{node.role}</td> <td className="px-5 py-3 text-muted-foreground">{node.role}</td>
<td className="px-5 py-3 text-muted-foreground">{node.user}</td> <td className="px-5 py-3 text-muted-foreground">{node.user}</td>
<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">{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>
</tr> </tr>
))} ))}
@@ -424,7 +494,7 @@ function TailscaleNodeTable() {
} }
export function TailscaleRoutesPage() { export function TailscaleRoutesPage() {
const routes = tailscaleNodes.filter((node) => node.routes !== '-'); const routes = mockTailscaleNodes.filter((node) => node.routes !== '-');
return ( return (
<> <>
<PageTitle eyebrow="테일스케일" title="라우트" description="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." /> <PageTitle eyebrow="테일스케일" title="라우트" description="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." />