diff --git a/src/App.tsx b/src/App.tsx index 5bd96f8..8ba211b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 { Activity, @@ -236,15 +236,6 @@ type ApiAppRelease = { app?: ApiApp; }; -type LicensePlanSummary = { - id: string; - code: string; - name: string; - assignedCount: number; - activeUserCount: number; - expiringSoonCount: number; -}; - type CommonAppPipeline = { id: string; repository: string; @@ -300,6 +291,17 @@ type RuntimePodMetric = { memory: string; }; +type DashboardJob = { + id: string; + status: string; +}; + +type ActiveWorkSummary = { + builds: number; + releases: number; + deployments: number; +}; + type DashboardRuntimeResource = RuntimeResource & { namespace: string; appName: string; @@ -349,22 +351,37 @@ function loadNotices() { .then((response) => response.notices ?? []); } -function loadLicensePlans() { - return apiRequest<{ plans?: LicensePlanSummary[] }>('/licenses/plans') - .then((response) => response.plans ?? []); -} - function loadCommonAppPipelines() { return apiRequest<{ repos: CommonAppPipeline[] }>('/woodpecker/repos') .then((result) => result.repos ?? []); } +function isActiveWorkStatus(status?: string | null) { + return status === 'PENDING' || status === 'RUNNING'; +} + +function loadActiveWorkSummary(releases: ApiAppRelease[]): Promise { + 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) { - return apiRequest(`/runtime/namespaces/${encodeURIComponent(namespace)}/resources`); + return apiRequest(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`); } function loadRuntimePodMetrics(namespace: string) { - return apiRequest(`/runtime/namespaces/${encodeURIComponent(namespace)}/pod-metrics`); + return apiRequest(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`); } const statusTone = { @@ -473,7 +490,21 @@ function Sidebar({ }) { const [openMenus, setOpenMenus] = useState>({}); 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 ? roleNavGroups .map((group) => { @@ -846,22 +877,19 @@ function SkeletonBlock({ className }: { className?: string }) { function MetricCards({ apps: dashboardApps, sessions, - licensePlans, + activeWork, runtimeResources, loading }: { apps: ApiApp[]; sessions: UserSessionRow[]; - licensePlans: LicensePlanSummary[]; + activeWork: ActiveWorkSummary; runtimeResources: DashboardRuntimeResource[]; loading: boolean; }) { const activeApps = dashboardApps.filter((app) => isActiveStatus(app.status)).length; const activeSessions = sessions.filter((session) => session.state === '활성').length; - const assignedLicenses = licensePlans.reduce((sum, plan) => sum + plan.assignedCount, 0); - 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 activeWorkTotal = activeWork.builds + activeWork.releases + activeWork.deployments; const healthyRuntimeCount = runtimeResources.filter(isRuntimeResourceHealthy).length; const runtimeRate = runtimeResources.length ? Math.round((healthyRuntimeCount / runtimeResources.length) * 100) : null; const cards = [ @@ -880,11 +908,11 @@ function MetricCards({ tone: 'green' as const }, { - label: '라이센스 사용률', - value: loading || licenseUsageRate === null ? '-' : `${licenseUsageRate}%`, - delta: assignedLicenses ? `${activeLicenseUsers}/${assignedLicenses}` : '할당 없음', - hint: expiringSoonLicenses ? `만료 예정 ${expiringSoonLicenses}명` : '활성 라이센스 사용자 기준', - tone: licenseUsageRate !== null && licenseUsageRate >= 90 ? 'amber' as const : 'green' as const + label: '진행 중 작업', + value: loading ? '-' : `${activeWorkTotal}건`, + delta: `빌드 ${activeWork.builds} · 릴리즈 ${activeWork.releases} · 배포 ${activeWork.deployments}`, + hint: '대기/실행 중 작업 기준', + tone: activeWorkTotal > 0 ? 'amber' as const : 'green' as const }, { label: '런타임 정상률', @@ -1355,7 +1383,7 @@ function DashboardPage({ const [dashboardApps, setDashboardApps] = useState([]); const [dashboardReleases, setDashboardReleases] = useState([]); const [recentSessions, setRecentSessions] = useState([]); - const [licensePlansSummary, setLicensePlansSummary] = useState([]); + const [activeWorkSummary, setActiveWorkSummary] = useState({ builds: 0, releases: 0, deployments: 0 }); const [commonAppPipelines, setCommonAppPipelines] = useState([]); const [dashboardNotices, setDashboardNotices] = useState([]); const [runtimeResources, setRuntimeResources] = useState([]); @@ -1366,13 +1394,12 @@ function DashboardPage({ async function loadDashboard() { setDashboardLoading(true); - const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, licensePlansResult, commonAppsResult] = await Promise.allSettled([ + const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([ loadLauncherVersions(), loadApps(), loadAppReleases(), loadUserSessions(), loadNotices(), - loadLicensePlans(), loadCommonAppPipelines() ]); @@ -1381,8 +1408,8 @@ function DashboardPage({ const nextReleases = releasesResult.status === 'fulfilled' ? releasesResult.value : []; const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : []; const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : []; - const nextLicensePlans = licensePlansResult.status === 'fulfilled' ? licensePlansResult.value : []; const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : []; + const nextActiveWork = await loadActiveWorkSummary(nextReleases); const namespaceApps = nextApps.filter((app) => app.namespace); const [runtimeResults, metricResults] = await Promise.all([ Promise.allSettled( @@ -1419,7 +1446,7 @@ function DashboardPage({ setDashboardApps(nextApps); setDashboardReleases(nextReleases); setRecentSessions(nextSessions); - setLicensePlansSummary(nextLicensePlans); + setActiveWorkSummary(nextActiveWork); setCommonAppPipelines(nextCommonApps); setDashboardNotices(nextNotices); setRuntimeResources(nextRuntimeResources); @@ -1444,7 +1471,7 @@ function DashboardPage({ description="플랫폼의 현재 상태와 운영 흐름을 빠르게 파악합니다." /> - +
diff --git a/src/pages/RestoredPages.tsx b/src/pages/RestoredPages.tsx index 9e07399..846209f 100644 --- a/src/pages/RestoredPages.tsx +++ b/src/pages/RestoredPages.tsx @@ -11,7 +11,6 @@ import { launcherCommonSettings, launcherVersions, runtimeCommands, - tailscaleNodes as mockTailscaleNodes, tailscaleSettings } from '../data/mock'; import { apiRequest } from '../lib/api'; @@ -50,6 +49,45 @@ type TailscaleNodesResponse = { 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; + tagOwners: Record; + 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); @@ -448,9 +486,23 @@ export function TailscaleStatusPage() { } 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 ( - + 총 {nodes.length}건 · {currentPage}/{totalPages}페이지} + />
@@ -475,7 +527,7 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin ) : null} - {nodes.map((node) => ( + {pageNodes.map((node) => ( @@ -489,27 +541,111 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin
표시할 노드가 없습니다.
{node.name} {node.ip}
+
+ + {nodes.length === 0 ? '0건' : `${(currentPage - 1) * pageSize + 1}-${Math.min(currentPage * pageSize, nodes.length)} / ${nodes.length}건`} + +
+ + +
+
); } export function TailscaleRoutesPage() { - const routes = mockTailscaleNodes.filter((node) => node.routes !== '-'); + const [routes, setRoutes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const refresh = () => { + setLoading(true); + setError(''); + apiRequest('/tailscale/routes') + .then((result) => { + setRoutes(result.routes ?? []); + setError(result.error ?? ''); + }) + .catch((requestError: Error) => setError(requestError.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + refresh(); + }, []); + return ( <> - + + + 새로고침 + + } + /> + {error ? ( + + {error} + + ) : null} - -
- {routes.map((route) => ( -
-
-

{route.routes}

-

{route.name} · {route.ip}

-
- -
- ))} + 총 {routes.length}건} /> +
+ + + + + + + + + + + + + + {loading && routes.length === 0 ? ( + + + + ) : null} + {!loading && routes.length === 0 ? ( + + + + ) : null} + {routes.map((route) => ( + + + + + + + + + + ))} + +
Route노드사용자광고승인Last Seen상태
라우트 정보를 불러오는 중입니다.
표시할 라우트가 없습니다.
{route.route}{route.nodeName}{route.user || '-'}{formatTailscaleLastSeen(route.lastSeenAt)}
@@ -517,28 +653,136 @@ export function TailscaleRoutesPage() { } export function TailscaleAclPage() { + const emptyPolicy: TailscaleAclPolicy = { groups: {}, tagOwners: {}, acls: [] }; + const [policy, setPolicy] = useState(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('/tailscale/acl') + .then((result) => { + setPolicy(result.policy ?? emptyPolicy); + setError(result.error ?? ''); + }) + .catch((requestError: Error) => setError(requestError.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + refresh(); + }, []); + return ( <> - -
+ + + 새로고침 + + } + /> + {error ? ( + + {error} + + ) : null} +
{[ - ['강사 장비 접근', 'group:instructors -> tag:teaching-device', '활성'], - ['학생 워크스페이스', 'group:students -> tag:assigned-workspace', '활성'], - ['운영자 전체 접근', 'group:ops -> tag:platform-admin', '활성'] - ].map(([title, description, status]) => ( + ['ACL', `${policy.acls.length}개`, '접근 허용 규칙'], + ['Groups', `${groups.length}개`, '사용자 그룹'], + ['Tag Owners', `${tagOwners.length}개`, '태그 소유자'] + ].map(([title, value, description]) => ( -
-

{title}

- -
-

{description}

+

{title}

+

{value}

+

{description}

))}
+
+ + 총 {policy.acls.length}건} /> +
+ + + + + + + + + + {loading && policy.acls.length === 0 ? ( + + + + ) : null} + {!loading && policy.acls.length === 0 ? ( + + + + ) : null} + {policy.acls.map((acl) => ( + + + + + + ))} + +
ActionSourceDestination
ACL 정보를 불러오는 중입니다.
표시할 ACL이 없습니다.
+
+ {acl.sources.length ? acl.sources.map((source) => {source}) : -} +
+
+
+ {acl.destinations.length ? acl.destinations.map((destination) => {destination}) : -} +
+
+
+
+
+ + +
+
); } +function PolicyMapCard({ title, description, entries, emptyText }: { title: string; description: string; entries: [string, string[]][]; emptyText: string }) { + return ( + + 총 {entries.length}건} /> +
+ {entries.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ {entries.map(([key, values]) => ( +
+

{key}

+
+ {values.length ? values.map((value) => {value}) : -} +
+
+ ))} +
+ )} +
+
+ ); +} + export function TailscaleDiagnosticsPage() { return ( <>