connect tailscale node status through platform gateway
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+84
-14
@@ -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="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." />
|
||||||
|
|||||||
Reference in New Issue
Block a user