1036 lines
42 KiB
TypeScript
1036 lines
42 KiB
TypeScript
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-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 {
|
|
agentDevices,
|
|
alerts,
|
|
gatewayConnections,
|
|
k3sWorkloads,
|
|
launcherVersions,
|
|
runtimeCommands,
|
|
tailscaleSettings
|
|
} from '../data/mock';
|
|
import { apiRequest } from '../lib/api';
|
|
import { PageTitle } from '../layout/AppLayout';
|
|
|
|
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
|
|
|
function tone(status: string): Tone {
|
|
if (['활성', '완료', '게시됨', '실행 중', '배포됨'].includes(status)) return 'green';
|
|
if (['주의', '예약됨', '대기', '준비중', '카나리'].includes(status)) return 'amber';
|
|
if (['잠김', '실패', '오프라인', '비활성'].includes(status)) return 'red';
|
|
return 'neutral';
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
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);
|
|
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);
|
|
const launcher = launcherVersions[0];
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="런처 운영"
|
|
title="버전 관리"
|
|
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태만 관리합니다."
|
|
actions={
|
|
<>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setSourceOpen(true)} disabled={!launcher}>
|
|
<Settings2 className="size-4" />
|
|
소스 연결
|
|
</Button>
|
|
<Button className="h-8 px-3 text-[13px]" onClick={() => setReleaseOpen(true)}>
|
|
<Plus className="size-4" />
|
|
릴리즈 등록
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{!launcher ? <Card className="px-5 py-8 text-center text-sm text-muted-foreground">등록된 런처 버전이 없습니다.</Card> : null}
|
|
{launcher ? (
|
|
<div className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
|
|
<Card>
|
|
<CardHeader title="메인 런처 배포 상태" description="앱 실행의 진입점인 런처 자체 버전과 배포 파일 상태입니다." />
|
|
<div className="p-5">
|
|
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/20 p-4 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-base font-semibold">{launcher.name}</p>
|
|
<StatusBadge status={launcher.status} />
|
|
</div>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
최신 {launcher.latestVersion} · 최소 지원 {launcher.minimumSupportedVersion || '-'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3">
|
|
<div className="rounded-md border border-border bg-background p-4">
|
|
<p className="text-xs text-muted-foreground">배포 파일</p>
|
|
<p className="mt-2 truncate text-sm font-semibold">{launcher.artifact}</p>
|
|
</div>
|
|
<div className="rounded-md border border-border bg-background p-4">
|
|
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
|
<p className="mt-2 truncate text-sm font-semibold text-muted-foreground">{launcher.downloadUrl}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-md border border-border bg-background p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-xs text-muted-foreground">소스 연결</p>
|
|
<Button variant="ghost" className="h-7 px-2 text-xs" onClick={() => setSourceOpen(true)}>
|
|
변경
|
|
</Button>
|
|
</div>
|
|
<div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Repository</p>
|
|
<p className="mt-1 truncate font-semibold">platform/launcher</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Branch</p>
|
|
<p className="mt-1 truncate font-semibold">release</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Commit SHA</p>
|
|
<p className="mt-1 truncate font-mono text-xs font-semibold">-</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader title="릴리즈 노트" description="런처 업데이트 안내와 운영 메모입니다." />
|
|
<div className="p-5 text-sm leading-6 text-muted-foreground">{launcher.releaseNotes}</div>
|
|
</Card>
|
|
</div>
|
|
) : null}
|
|
|
|
<Modal
|
|
open={sourceOpen}
|
|
title="런처 소스 연결"
|
|
onClose={() => setSourceOpen(false)}
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setSourceOpen(false)}>취소</Button>
|
|
<Button onClick={() => setSourceOpen(false)}>저장</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="grid gap-4">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Repository</span>
|
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" defaultValue="platform/launcher" />
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Branch</span>
|
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" defaultValue="release" />
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" />
|
|
</label>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={releaseOpen}
|
|
title="릴리즈 등록"
|
|
onClose={() => setReleaseOpen(false)}
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setReleaseOpen(false)}>취소</Button>
|
|
<Button onClick={() => setReleaseOpen(false)}>등록</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="grid gap-4">
|
|
{['버전', '최소 지원 버전', '배포 파일', '다운로드 URL', 'Repository', 'Branch', 'Commit SHA'].map((label) => (
|
|
<label className="block" key={label}>
|
|
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" />
|
|
</label>
|
|
))}
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">릴리즈 노트</span>
|
|
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" />
|
|
</label>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function LauncherAppsPage() {
|
|
const linked = launcherApps().filter((app) => app.launcherLinked).sort((a, b) => (a.launcherOrder ?? 999) - (b.launcherOrder ?? 999));
|
|
const unlinked = launcherApps().filter((app) => !app.launcherLinked);
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="런처 운영"
|
|
title="앱 연결"
|
|
description="앱 구성에 등록된 제품 앱 중 런처 홈에 노출하고 실행할 앱을 연결합니다."
|
|
actions={
|
|
<Button className="h-8 px-3 text-[13px]">
|
|
<Plus className="size-4" />
|
|
앱 연결
|
|
</Button>
|
|
}
|
|
/>
|
|
<div className="space-y-4">
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="연결된 앱" description="런처 홈에 표시할 앱과 노출 순서를 관리합니다." />
|
|
<div className="max-h-[520px] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full min-w-[760px] text-left text-sm">
|
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="w-28 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 text-right font-medium">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{linked.map((app) => (
|
|
<tr className="hover:bg-muted/20" key={app.key}>
|
|
<td className="px-5 py-3 font-semibold">{app.launcherOrder ?? '-'}</td>
|
|
<td className="px-5 py-3 font-semibold">{app.name}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{app.release}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{app.launchUrl}</td>
|
|
<td className="px-5 py-3">
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" className="size-8 px-0" aria-label={`${app.name} 위로 이동`}>
|
|
<ChevronUp className="size-4" />
|
|
</Button>
|
|
<Button variant="outline" className="size-8 px-0" aria-label={`${app.name} 아래로 이동`}>
|
|
<ChevronDown className="size-4" />
|
|
</Button>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">해제</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="미연결 앱" description="아직 런처에 연결하지 않은 앱입니다." />
|
|
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
|
|
{unlinked.map((app) => (
|
|
<div className="flex items-center justify-between gap-4 px-5 py-3" key={app.key}>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{app.name}</p>
|
|
<p className="mt-1 truncate text-xs text-muted-foreground">{app.key} · {app.release}</p>
|
|
</div>
|
|
<Button className="h-8 px-3 text-[13px]">연결</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function TailscaleStatusPage() {
|
|
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 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다."
|
|
actions={
|
|
<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">
|
|
{[
|
|
['등록 노드', `${nodes.length}대`, source ? `${source} 수집 기준` : '게이트웨이 수집 기준'],
|
|
['온라인', `${online}대`, '최근 접속 정상'],
|
|
['Subnet Router', `${subnetRouters}대`, '클러스터 CIDR 광고']
|
|
].map(([label, value, description]) => (
|
|
<Card className="p-5" key={label}>
|
|
<p className="text-sm text-muted-foreground">{label}</p>
|
|
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
|
</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 nodes={nodes} loading={loading} />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다."
|
|
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">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">노드</th>
|
|
<th className="px-5 py-3 font-medium">IP</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">Routes</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 && 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}
|
|
{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>
|
|
<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">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
|
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
|
</tr>
|
|
))}
|
|
</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, 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="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="현재 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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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="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">
|
|
{[
|
|
['ACL', `${policy.acls.length}개`, '접근 허용 규칙'],
|
|
['Groups', `${groups.length}개`, '사용자 그룹'],
|
|
['Tag Owners', `${tagOwners.length}개`, '태그 소유자']
|
|
].map(([title, value, description]) => (
|
|
<Card className="p-5" key={title}>
|
|
<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 (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="테일스케일"
|
|
title="연결 진단"
|
|
description="Tailnet, subnet route, 플랫폼 게이트웨이 연동 상태를 진단합니다."
|
|
actions={
|
|
<Button className="h-8 px-3 text-[13px]">
|
|
<RefreshCcw className="size-4" />
|
|
진단 실행
|
|
</Button>
|
|
}
|
|
/>
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="진단 항목" description="런처에는 연결 정보만 제공하고, 실제 세부 설정은 여기에서 관리합니다." />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[880px] text-left text-sm">
|
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">항목</th>
|
|
<th className="px-5 py-3 font-medium">Key</th>
|
|
<th className="px-5 py-3 font-medium">Value</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{tailscaleSettings.map((setting) => (
|
|
<tr className="hover:bg-muted/20" key={setting.key}>
|
|
<td className="px-5 py-3">
|
|
<p className="font-medium">{setting.name}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{setting.description}</p>
|
|
</td>
|
|
<td className="px-5 py-3 font-medium">{setting.key}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{setting.value}</td>
|
|
<td className="px-5 py-3"><StatusBadge status={setting.status} /></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function GatewayStatusPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 게이트웨이" title="연결 상태" description="Headscale, Agent, 저장소, 런타임 명령 버스의 연결 상태를 확인합니다." />
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{gatewayConnections.map((connection) => (
|
|
<Card className="p-5" key={connection.name}>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="truncate text-sm font-semibold">{connection.name}</p>
|
|
<StatusBadge status={connection.status} />
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted-foreground">{connection.role}</p>
|
|
<p className="mt-4 truncate text-sm font-medium">{connection.endpoint}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">마지막 동기화 {connection.lastSync}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function GatewayAgentsPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 게이트웨이" title="Agent 관리" description="플랫폼 게이트웨이가 수집한 Agent 등록 및 상태 정보를 관리합니다." />
|
|
<AgentTable title="Agent 목록" description="등록된 운영 장비 Agent입니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function GatewayDevicesPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 게이트웨이" title="장비 동기화" description="계정, 장비, 워크스페이스 정보를 인증 서버와 Agent 상태 기준으로 동기화합니다." />
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{[
|
|
['계정/장비 매핑', 'Auth Backend -> Gateway', '활성'],
|
|
['워크스페이스 경로', 'Agent -> Gateway', '활성'],
|
|
['원격 실행 상태', 'Runtime -> Gateway', '주의']
|
|
].map(([title, description, status]) => (
|
|
<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>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function RuntimeDevicesPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 런타임" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
|
<AgentTable
|
|
title="장비 목록"
|
|
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
|
withCommands
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AgentTable({ title, description, withCommands = false }: { title: string; description: string; withCommands?: boolean }) {
|
|
return (
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader
|
|
title={title}
|
|
description={description}
|
|
action={
|
|
withCommands ? (
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
|
<SlidersHorizontal className="size-4" />
|
|
상태 필터
|
|
</Button>
|
|
<Button className="h-8 px-3 text-[13px]">
|
|
<RefreshCcw className="size-4" />
|
|
상태 갱신
|
|
</Button>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
/>
|
|
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full min-w-[1040px] text-left text-sm">
|
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
<tr>
|
|
<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">Workspace</th>
|
|
<th className="px-5 py-3 font-medium">SSH 키</th>
|
|
<th className="px-5 py-3 font-medium">Last Seen</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
{withCommands ? <th className="px-5 py-3 text-right font-medium">명령</th> : null}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{agentDevices.map((device) => (
|
|
<tr className="hover:bg-muted/20" key={device.name}>
|
|
<td className="px-5 py-3 font-medium">{device.name}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
|
<td className="px-5 py-3">{device.sshKey}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
|
<td className="px-5 py-3"><StatusBadge status={device.status} /></td>
|
|
{withCommands ? (
|
|
<td className="px-5 py-3">
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
|
<Button variant="ghost" className="size-8 px-0" aria-label="상세">
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
) : null}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function AgentRegistrationPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="장비 등록" description="새 장비에 설치할 Agent 등록 토큰과 연결 정보를 발급합니다." />
|
|
<AgentPlaceholder title="장비 등록 요청" description="Agent 등록 요청과 토큰 발급 상태를 확인합니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AgentInstallStatusPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="설치 상태" description="장비별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
|
<AgentPlaceholder title="설치 상태" description="설치 완료, 대기, 실패 상태를 확인합니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AgentSshKeysPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
|
<AgentPlaceholder title="SSH 키 배포" description="장비별 SSH 키 배포 상태입니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AgentWorkspacePage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="Workspace 설정" description="장비별 작업 디렉터리와 동기화 정책을 관리합니다." />
|
|
<AgentPlaceholder title="Workspace 설정" description="계정별 workspace 경로와 동기화 상태입니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AgentCommandsPage() {
|
|
const columns = useMemo<Array<DataTableColumn<(typeof runtimeCommands)[number]>>>(
|
|
() => [
|
|
{ key: 'target', header: '장비', cell: (row) => <span className="font-semibold">{row.target}</span> },
|
|
{ key: 'command', header: '명령', cell: (row) => row.command },
|
|
{ key: 'requestedBy', header: '요청자', cell: (row) => row.requestedBy },
|
|
{ key: 'time', header: '시간', cell: (row) => row.time },
|
|
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status} /> }
|
|
],
|
|
[]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
|
<DataTableCard
|
|
title="최근 제어 작업"
|
|
rows={runtimeCommands}
|
|
columns={columns}
|
|
getRowKey={(row) => `${row.target}-${row.command}-${row.time}`}
|
|
getSearchText={(row) => `${row.target} ${row.command} ${row.requestedBy} ${row.status}`}
|
|
pageSize={4}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AgentPlaceholder({ title, description }: { title: string; description: string }) {
|
|
return (
|
|
<Card>
|
|
<CardHeader title={title} description={description} />
|
|
<div className="p-5">
|
|
<AgentTable title="Agent 장비" description="등록된 장비 목록입니다." />
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function MonitoringPage() {
|
|
const columns = useMemo<Array<DataTableColumn<(typeof k3sWorkloads)[number]>>>(
|
|
() => [
|
|
{ key: 'workload', header: '워크로드', cell: (row) => <div><p className="font-semibold">{row.workload}</p><p className="mt-1 text-xs text-muted-foreground">{row.namespace}</p></div> },
|
|
{ key: 'pod', header: 'Pod', cell: (row) => row.pod },
|
|
{ key: 'replicas', header: 'Replicas', cell: (row) => row.replicas },
|
|
{ key: 'cpu', header: 'CPU', cell: (row) => <Progress value={row.cpu} /> },
|
|
{ key: 'memory', header: 'Memory', cell: (row) => <Progress value={row.memory} tone="amber" /> },
|
|
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status} /> }
|
|
],
|
|
[]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="시스템" title="모니터링" />
|
|
<DataTableCard
|
|
title="워크로드"
|
|
rows={k3sWorkloads}
|
|
columns={columns}
|
|
getRowKey={(row) => `${row.namespace}-${row.workload}-${row.pod}`}
|
|
getSearchText={(row) => `${row.namespace} ${row.workload} ${row.pod} ${row.status}`}
|
|
minWidth={1100}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function PoliciesPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="시스템" title="정책" />
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{alerts.map((alert) => {
|
|
const Icon = alert.icon;
|
|
return (
|
|
<Card key={alert.label} className="p-5">
|
|
<Icon className="size-5 text-primary" />
|
|
<p className="mt-4 text-sm font-semibold">{alert.label}</p>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function launcherApps() {
|
|
return [
|
|
{
|
|
name: 'ADS IDE',
|
|
key: 'ADS IDE',
|
|
release: 'ADS-IDE-1.1.0.AppImage',
|
|
launcherLinked: true,
|
|
launcherOrder: 1,
|
|
launchUrl: 'Ingress 예정 / localhost:5175'
|
|
},
|
|
{
|
|
name: 'LMS',
|
|
key: 'LMS',
|
|
release: 'LMS-1.0.0.AppImage',
|
|
launcherLinked: true,
|
|
launcherOrder: 2,
|
|
launchUrl: 'Ingress 예정 / localhost:5176'
|
|
},
|
|
{
|
|
name: 'Data Management',
|
|
key: 'Data Management',
|
|
release: 'data-management',
|
|
launcherLinked: false,
|
|
launcherOrder: null,
|
|
launchUrl: '미설정'
|
|
},
|
|
{
|
|
name: 'FMS',
|
|
key: 'FMS',
|
|
release: 'fms',
|
|
launcherLinked: true,
|
|
launcherOrder: 3,
|
|
launchUrl: '미설정'
|
|
},
|
|
{
|
|
name: 'ADS Education',
|
|
key: 'ADS Education',
|
|
release: 'ads-education',
|
|
launcherLinked: true,
|
|
launcherOrder: 4,
|
|
launchUrl: '미설정'
|
|
},
|
|
{
|
|
name: 'Calibration',
|
|
key: 'Calibration',
|
|
release: 'calibration',
|
|
launcherLinked: false,
|
|
launcherOrder: null,
|
|
launchUrl: '미설정'
|
|
},
|
|
{
|
|
name: 'HDMAP Builder',
|
|
key: 'HDMAP Builder',
|
|
release: 'hdmap-builder',
|
|
launcherLinked: false,
|
|
launcherOrder: null,
|
|
launchUrl: '미설정'
|
|
}
|
|
];
|
|
}
|