merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 16:39:15 +09:00
+84 -14
View File
@@ -1,5 +1,5 @@
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 { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
import {
@@ -11,9 +11,10 @@ import {
launcherCommonSettings,
launcherVersions,
runtimeCommands,
tailscaleNodes,
tailscaleNodes as mockTailscaleNodes,
tailscaleSettings
} from '../data/mock';
import { apiRequest } from '../lib/api';
import { PageTitle } from '../layout/AppLayout';
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>;
}
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() {
const [sourceOpen, setSourceOpen] = useState(false);
const [releaseOpen, setReleaseOpen] = useState(false);
@@ -351,25 +383,48 @@ export function LauncherCommonSettingsPage() {
}
export function TailscaleStatusPage() {
const online = tailscaleNodes.filter((node) => node.status === '활성').length;
const subnetRouters = tailscaleNodes.filter((node) => node.routes !== '-').length;
const [nodes, setNodes] = useState<TailscaleNode[]>([]);
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 (
<>
<PageTitle
eyebrow="테일스케일"
title="노드 현황"
description="플랫폼 게이트웨이가 수집한 tailnet 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다."
description="tailnet 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다."
actions={
<Button className="h-8 px-3 text-[13px]">
<RefreshCcw className="size-4" />
상태 갱신
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RefreshCcw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
새로고침
</Button>
}
/>
<div className="grid gap-4 md:grid-cols-3">
{[
['등록 노드', `${tailscaleNodes.length}대`, '게이트웨이 수집 기준'],
['등록 노드', `${nodes.length}대`, source ? `${source} 수집 기준` : '게이트웨이 수집 기준'],
['온라인', `${online}대`, '최근 접속 정상'],
['Subnet Router', `${subnetRouters}대`, '클러스터 CIDR 광고']
].map(([label, value, description]) => (
@@ -380,14 +435,19 @@ export function TailscaleStatusPage() {
</Card>
))}
</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">
<TailscaleNodeTable />
<TailscaleNodeTable nodes={nodes} loading={loading} />
</div>
</>
);
}
function TailscaleNodeTable() {
function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader title="노드 현황" description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다." />
@@ -405,14 +465,24 @@ function TailscaleNodeTable() {
</tr>
</thead>
<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}>
<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 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.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>
</tr>
))}
@@ -424,7 +494,7 @@ function TailscaleNodeTable() {
}
export function TailscaleRoutesPage() {
const routes = tailscaleNodes.filter((node) => node.routes !== '-');
const routes = mockTailscaleNodes.filter((node) => node.routes !== '-');
return (
<>
<PageTitle eyebrow="테일스케일" title="라우트" description="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." />