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:
+62
-35
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -236,15 +236,6 @@ type ApiAppRelease = {
|
|||||||
app?: ApiApp;
|
app?: ApiApp;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LicensePlanSummary = {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
assignedCount: number;
|
|
||||||
activeUserCount: number;
|
|
||||||
expiringSoonCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CommonAppPipeline = {
|
type CommonAppPipeline = {
|
||||||
id: string;
|
id: string;
|
||||||
repository: string;
|
repository: string;
|
||||||
@@ -300,6 +291,17 @@ type RuntimePodMetric = {
|
|||||||
memory: string;
|
memory: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DashboardJob = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActiveWorkSummary = {
|
||||||
|
builds: number;
|
||||||
|
releases: number;
|
||||||
|
deployments: number;
|
||||||
|
};
|
||||||
|
|
||||||
type DashboardRuntimeResource = RuntimeResource & {
|
type DashboardRuntimeResource = RuntimeResource & {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
appName: string;
|
appName: string;
|
||||||
@@ -349,22 +351,37 @@ function loadNotices() {
|
|||||||
.then((response) => response.notices ?? []);
|
.then((response) => response.notices ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLicensePlans() {
|
|
||||||
return apiRequest<{ plans?: LicensePlanSummary[] }>('/licenses/plans')
|
|
||||||
.then((response) => response.plans ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCommonAppPipelines() {
|
function loadCommonAppPipelines() {
|
||||||
return apiRequest<{ repos: CommonAppPipeline[] }>('/woodpecker/repos')
|
return apiRequest<{ repos: CommonAppPipeline[] }>('/woodpecker/repos')
|
||||||
.then((result) => result.repos ?? []);
|
.then((result) => result.repos ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActiveWorkStatus(status?: string | null) {
|
||||||
|
return status === 'PENDING' || status === 'RUNNING';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadActiveWorkSummary(releases: ApiAppRelease[]): Promise<ActiveWorkSummary> {
|
||||||
|
const activeReleases = releases.filter((release) => isActiveWorkStatus(release.status)).length;
|
||||||
|
return Promise.allSettled([
|
||||||
|
apiRequest<{ jobs?: DashboardJob[] }>('/apps/builds?page=1&limit=20'),
|
||||||
|
apiRequest<{ deployments?: DashboardJob[] }>('/apps/deployments?page=1&limit=20')
|
||||||
|
]).then(([buildsResult, deploymentsResult]) => ({
|
||||||
|
builds: buildsResult.status === 'fulfilled'
|
||||||
|
? (buildsResult.value.jobs ?? []).filter((job) => isActiveWorkStatus(job.status)).length
|
||||||
|
: 0,
|
||||||
|
releases: activeReleases,
|
||||||
|
deployments: deploymentsResult.status === 'fulfilled'
|
||||||
|
? (deploymentsResult.value.deployments ?? []).filter((job) => isActiveWorkStatus(job.status)).length
|
||||||
|
: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function loadRuntimeNamespaceResources(namespace: string) {
|
function loadRuntimeNamespaceResources(namespace: string) {
|
||||||
return apiRequest<RuntimeResourceResponse>(`/runtime/namespaces/${encodeURIComponent(namespace)}/resources`);
|
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRuntimePodMetrics(namespace: string) {
|
function loadRuntimePodMetrics(namespace: string) {
|
||||||
return apiRequest<RuntimePodMetric[]>(`/runtime/namespaces/${encodeURIComponent(namespace)}/pod-metrics`);
|
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusTone = {
|
const statusTone = {
|
||||||
@@ -473,7 +490,21 @@ function Sidebar({
|
|||||||
}) {
|
}) {
|
||||||
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
||||||
const normalizedMenuSearch = menuSearch.trim().toLowerCase();
|
const normalizedMenuSearch = menuSearch.trim().toLowerCase();
|
||||||
const roleNavGroups = filterNavGroupsByRole(navGroups, currentAdmin?.role) as typeof navGroups;
|
const roleNavGroups = useMemo(
|
||||||
|
() => filterNavGroupsByRole(navGroups, currentAdmin?.role) as typeof navGroups,
|
||||||
|
[currentAdmin?.role]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
for (const group of roleNavGroups) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (!('children' in item) || !item.children) continue;
|
||||||
|
const hasActiveChild = item.children.some((child) => resolveNavView(child) === activeView);
|
||||||
|
if (!hasActiveChild) continue;
|
||||||
|
setOpenMenus((prev) => (prev[item.label] ? prev : { ...prev, [item.label]: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeView, roleNavGroups]);
|
||||||
const visibleGroups = normalizedMenuSearch
|
const visibleGroups = normalizedMenuSearch
|
||||||
? roleNavGroups
|
? roleNavGroups
|
||||||
.map((group) => {
|
.map((group) => {
|
||||||
@@ -846,22 +877,19 @@ function SkeletonBlock({ className }: { className?: string }) {
|
|||||||
function MetricCards({
|
function MetricCards({
|
||||||
apps: dashboardApps,
|
apps: dashboardApps,
|
||||||
sessions,
|
sessions,
|
||||||
licensePlans,
|
activeWork,
|
||||||
runtimeResources,
|
runtimeResources,
|
||||||
loading
|
loading
|
||||||
}: {
|
}: {
|
||||||
apps: ApiApp[];
|
apps: ApiApp[];
|
||||||
sessions: UserSessionRow[];
|
sessions: UserSessionRow[];
|
||||||
licensePlans: LicensePlanSummary[];
|
activeWork: ActiveWorkSummary;
|
||||||
runtimeResources: DashboardRuntimeResource[];
|
runtimeResources: DashboardRuntimeResource[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const activeApps = dashboardApps.filter((app) => isActiveStatus(app.status)).length;
|
const activeApps = dashboardApps.filter((app) => isActiveStatus(app.status)).length;
|
||||||
const activeSessions = sessions.filter((session) => session.state === '활성').length;
|
const activeSessions = sessions.filter((session) => session.state === '활성').length;
|
||||||
const assignedLicenses = licensePlans.reduce((sum, plan) => sum + plan.assignedCount, 0);
|
const activeWorkTotal = activeWork.builds + activeWork.releases + activeWork.deployments;
|
||||||
const activeLicenseUsers = licensePlans.reduce((sum, plan) => sum + plan.activeUserCount, 0);
|
|
||||||
const expiringSoonLicenses = licensePlans.reduce((sum, plan) => sum + plan.expiringSoonCount, 0);
|
|
||||||
const licenseUsageRate = assignedLicenses ? Math.round((activeLicenseUsers / assignedLicenses) * 100) : null;
|
|
||||||
const healthyRuntimeCount = runtimeResources.filter(isRuntimeResourceHealthy).length;
|
const healthyRuntimeCount = runtimeResources.filter(isRuntimeResourceHealthy).length;
|
||||||
const runtimeRate = runtimeResources.length ? Math.round((healthyRuntimeCount / runtimeResources.length) * 100) : null;
|
const runtimeRate = runtimeResources.length ? Math.round((healthyRuntimeCount / runtimeResources.length) * 100) : null;
|
||||||
const cards = [
|
const cards = [
|
||||||
@@ -880,11 +908,11 @@ function MetricCards({
|
|||||||
tone: 'green' as const
|
tone: 'green' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '라이센스 사용률',
|
label: '진행 중 작업',
|
||||||
value: loading || licenseUsageRate === null ? '-' : `${licenseUsageRate}%`,
|
value: loading ? '-' : `${activeWorkTotal}건`,
|
||||||
delta: assignedLicenses ? `${activeLicenseUsers}/${assignedLicenses}` : '할당 없음',
|
delta: `빌드 ${activeWork.builds} · 릴리즈 ${activeWork.releases} · 배포 ${activeWork.deployments}`,
|
||||||
hint: expiringSoonLicenses ? `만료 예정 ${expiringSoonLicenses}명` : '활성 라이센스 사용자 기준',
|
hint: '대기/실행 중 작업 기준',
|
||||||
tone: licenseUsageRate !== null && licenseUsageRate >= 90 ? 'amber' as const : 'green' as const
|
tone: activeWorkTotal > 0 ? 'amber' as const : 'green' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '런타임 정상률',
|
label: '런타임 정상률',
|
||||||
@@ -1355,7 +1383,7 @@ function DashboardPage({
|
|||||||
const [dashboardApps, setDashboardApps] = useState<ApiApp[]>([]);
|
const [dashboardApps, setDashboardApps] = useState<ApiApp[]>([]);
|
||||||
const [dashboardReleases, setDashboardReleases] = useState<ApiAppRelease[]>([]);
|
const [dashboardReleases, setDashboardReleases] = useState<ApiAppRelease[]>([]);
|
||||||
const [recentSessions, setRecentSessions] = useState<UserSessionRow[]>([]);
|
const [recentSessions, setRecentSessions] = useState<UserSessionRow[]>([]);
|
||||||
const [licensePlansSummary, setLicensePlansSummary] = useState<LicensePlanSummary[]>([]);
|
const [activeWorkSummary, setActiveWorkSummary] = useState<ActiveWorkSummary>({ builds: 0, releases: 0, deployments: 0 });
|
||||||
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
|
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
|
||||||
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
|
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
|
||||||
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
|
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
|
||||||
@@ -1366,13 +1394,12 @@ function DashboardPage({
|
|||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
setDashboardLoading(true);
|
setDashboardLoading(true);
|
||||||
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, licensePlansResult, commonAppsResult] = await Promise.allSettled([
|
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([
|
||||||
loadLauncherVersions(),
|
loadLauncherVersions(),
|
||||||
loadApps(),
|
loadApps(),
|
||||||
loadAppReleases(),
|
loadAppReleases(),
|
||||||
loadUserSessions(),
|
loadUserSessions(),
|
||||||
loadNotices(),
|
loadNotices(),
|
||||||
loadLicensePlans(),
|
|
||||||
loadCommonAppPipelines()
|
loadCommonAppPipelines()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1381,8 +1408,8 @@ function DashboardPage({
|
|||||||
const nextReleases = releasesResult.status === 'fulfilled' ? releasesResult.value : [];
|
const nextReleases = releasesResult.status === 'fulfilled' ? releasesResult.value : [];
|
||||||
const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
||||||
const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : [];
|
const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : [];
|
||||||
const nextLicensePlans = licensePlansResult.status === 'fulfilled' ? licensePlansResult.value : [];
|
|
||||||
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
|
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
|
||||||
|
const nextActiveWork = await loadActiveWorkSummary(nextReleases);
|
||||||
const namespaceApps = nextApps.filter((app) => app.namespace);
|
const namespaceApps = nextApps.filter((app) => app.namespace);
|
||||||
const [runtimeResults, metricResults] = await Promise.all([
|
const [runtimeResults, metricResults] = await Promise.all([
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
@@ -1419,7 +1446,7 @@ function DashboardPage({
|
|||||||
setDashboardApps(nextApps);
|
setDashboardApps(nextApps);
|
||||||
setDashboardReleases(nextReleases);
|
setDashboardReleases(nextReleases);
|
||||||
setRecentSessions(nextSessions);
|
setRecentSessions(nextSessions);
|
||||||
setLicensePlansSummary(nextLicensePlans);
|
setActiveWorkSummary(nextActiveWork);
|
||||||
setCommonAppPipelines(nextCommonApps);
|
setCommonAppPipelines(nextCommonApps);
|
||||||
setDashboardNotices(nextNotices);
|
setDashboardNotices(nextNotices);
|
||||||
setRuntimeResources(nextRuntimeResources);
|
setRuntimeResources(nextRuntimeResources);
|
||||||
@@ -1444,7 +1471,7 @@ function DashboardPage({
|
|||||||
description="플랫폼의 현재 상태와 운영 흐름을 빠르게 파악합니다."
|
description="플랫폼의 현재 상태와 운영 흐름을 빠르게 파악합니다."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCards apps={dashboardApps} sessions={recentSessions} licensePlans={licensePlansSummary} runtimeResources={runtimeResources} loading={dashboardLoading} />
|
<MetricCards apps={dashboardApps} sessions={recentSessions} activeWork={activeWorkSummary} runtimeResources={runtimeResources} loading={dashboardLoading} />
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||||
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
|
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
|
||||||
|
|||||||
+271
-27
@@ -11,7 +11,6 @@ import {
|
|||||||
launcherCommonSettings,
|
launcherCommonSettings,
|
||||||
launcherVersions,
|
launcherVersions,
|
||||||
runtimeCommands,
|
runtimeCommands,
|
||||||
tailscaleNodes as mockTailscaleNodes,
|
|
||||||
tailscaleSettings
|
tailscaleSettings
|
||||||
} from '../data/mock';
|
} from '../data/mock';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
@@ -50,6 +49,45 @@ type TailscaleNodesResponse = {
|
|||||||
error?: 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) {
|
function formatTailscaleLastSeen(value: string | null) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
const time = Date.parse(value);
|
const time = Date.parse(value);
|
||||||
@@ -448,9 +486,23 @@ export function TailscaleStatusPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
|
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 (
|
return (
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[900px] text-left text-sm">
|
<table className="w-full min-w-[900px] text-left text-sm">
|
||||||
<thead className="border-b border-border text-xs text-muted-foreground">
|
<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>
|
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{nodes.map((node) => (
|
{pageNodes.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>
|
||||||
@@ -489,27 +541,111 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TailscaleRoutesPage() {
|
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 (
|
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>
|
<Card>
|
||||||
<CardHeader title="라우트 광고" description="승인된 subnet route와 노드 상태입니다." />
|
<CardHeader title="라우트 목록" description="현재 Headscale 노드 기준 라우트 목록입니다." action={<span className="text-xs font-semibold text-muted-foreground">총 {routes.length}건</span>} />
|
||||||
<div className="divide-y divide-border">
|
<div className="overflow-x-auto">
|
||||||
{routes.map((route) => (
|
<table className="w-full min-w-[900px] text-left text-sm">
|
||||||
<div className="flex items-center justify-between gap-4 px-5 py-3" key={route.name}>
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
||||||
<div className="min-w-0">
|
<tr>
|
||||||
<p className="truncate text-sm font-semibold">{route.routes}</p>
|
<th className="px-5 py-3 font-medium">Route</th>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{route.name} · {route.ip}</p>
|
<th className="px-5 py-3 font-medium">노드</th>
|
||||||
</div>
|
<th className="px-5 py-3 font-medium">사용자</th>
|
||||||
<StatusBadge status={route.status} />
|
<th className="px-5 py-3 font-medium">광고</th>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
@@ -517,28 +653,136 @@ export function TailscaleRoutesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TailscaleAclPage() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="테일스케일" title="ACL" description="사용자 그룹과 장비 태그 기준의 tailnet 접근 정책을 관리합니다." />
|
<PageTitle
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
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', '활성'],
|
['ACL', `${policy.acls.length}개`, '접근 허용 규칙'],
|
||||||
['학생 워크스페이스', 'group:students -> tag:assigned-workspace', '활성'],
|
['Groups', `${groups.length}개`, '사용자 그룹'],
|
||||||
['운영자 전체 접근', 'group:ops -> tag:platform-admin', '활성']
|
['Tag Owners', `${tagOwners.length}개`, '태그 소유자']
|
||||||
].map(([title, description, status]) => (
|
].map(([title, value, description]) => (
|
||||||
<Card className="p-5" key={title}>
|
<Card className="p-5" key={title}>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<p className="text-sm text-muted-foreground">{title}</p>
|
||||||
<p className="text-sm font-semibold">{title}</p>
|
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
||||||
<StatusBadge status={status} />
|
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-xs text-muted-foreground">{description}</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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() {
|
export function TailscaleDiagnosticsPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user