Files
studio-admin-fe/src/App.tsx
T
chyoo 3930da8ef2
ci/woodpecker/push/woodpecker Pipeline was successful
connect dashboard license usage
2026-07-07 09:05:17 +09:00

3564 lines
146 KiB
TypeScript

import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Activity,
ChevronDown,
ChevronRight,
CircleHelp,
ExternalLink,
GitBranch,
Copy,
KeyRound,
Lock,
LogOut,
MoreHorizontal,
Plus,
Play,
RotateCcw,
Search,
Settings2,
ShieldCheck,
SlidersHorizontal,
Trash2
} from 'lucide-react';
import {
alerts,
authApiEndpoints,
appBundles,
appDeploymentJobs,
appMigrationJobs,
appReleasePipelines,
appSourceConfigs,
apps,
containerBlueprints,
k3sWorkloads,
agentDevices,
gatewayConnections,
launcherCommonSettings,
launcherVersions,
licensePlans,
metrics,
navGroups,
notices,
tailscaleNodes,
tailscaleSettings,
runtimeCommands,
userLicenseAssignments
} from './data/mock';
import { Badge, Button, Card, CardHeader, Progress } from './components/ui';
import { useToast } from './components/toast';
import { DataTableCard, type DataTableColumn, type DataTableFilter } from './components/data-table';
import { formatDateTime as formatDateTimeValue } from './lib/date-format';
import { apiFetch, authExpiredEventName, authHeaders, clearAdminSession } from './lib/api';
import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
import { cn } from './lib/utils';
import { getStoredSelectedAppId, setStoredSelectedAppId } from './lib/selected-app';
import { AppManagementPage as AppManagementApiPage } from './pages/applications/AppManagementPage';
import { BuildManagementPage } from './pages/applications/BuildManagementPage';
import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage';
import { MigrationManagementPage } from './pages/applications/MigrationManagementPage';
import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPage';
import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage';
import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage';
import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage';
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage';
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
import { RuntimeLogsPage as RuntimeLogsApiPage } from './pages/runtime/RuntimeLogsPage';
import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage';
import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage';
import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage';
import { LauncherCommonSettingsPage as LauncherCommonSettingsApiPage } from './pages/launcher/LauncherCommonSettingsPage';
import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
import {
AgentCommandsPage as AgentCommandsRestoredPage,
AgentInstallStatusPage as AgentInstallStatusRestoredPage,
AgentRegistrationPage as AgentRegistrationRestoredPage,
AgentSshKeysPage as AgentSshKeysRestoredPage,
AgentWorkspacePage as AgentWorkspaceRestoredPage,
GatewayAgentsPage as GatewayAgentsRestoredPage,
GatewayDevicesPage as GatewayDevicesRestoredPage,
GatewayStatusPage as GatewayStatusRestoredPage,
LauncherCommonSettingsPage as LauncherCommonSettingsRestoredPage,
TailscaleAclPage as TailscaleAclRestoredPage,
TailscaleDiagnosticsPage as TailscaleDiagnosticsRestoredPage,
TailscaleRoutesPage as TailscaleRoutesRestoredPage,
TailscaleStatusPage as TailscaleStatusRestoredPage
} from './pages/RestoredPages';
type ViewKey =
| 'dashboard'
| 'launcherSources'
| 'launcherBuilds'
| 'launcherVersions'
| 'launcherComponents'
| 'launcherApps'
| 'launcherCommonSettings'
| 'notices'
| 'appManagement'
| 'appSources'
| 'appBuilds'
| 'appReleases'
| 'appDeployments'
| 'appMigrations'
| 'gatewayStatus'
| 'gatewayAgents'
| 'gatewayDevices'
| 'tailscaleStatus'
| 'tailscaleRoutes'
| 'tailscaleAcl'
| 'tailscaleDiagnostics'
| 'agentRegistration'
| 'agentInstallStatus'
| 'agentSshKeys'
| 'agentWorkspace'
| 'agentCommands'
| 'runtimeConfig'
| 'runtimeLogs'
| 'runtimeDeviceControl'
| 'adminManagement'
| 'userSessions'
| 'licenses';
const viewByLabel: Record<string, ViewKey> = {
대시보드: 'dashboard',
'버전 관리': 'launcherVersions',
구성요소: 'launcherComponents',
'앱 연결': 'launcherApps',
'공통 설정': 'launcherCommonSettings',
공지사항: 'notices',
'앱 관리': 'appManagement',
'소스 관리': 'appSources',
'빌드 관리': 'appBuilds',
'릴리즈 관리': 'appReleases',
'실행 설정': 'runtimeConfig',
'배포 관리': 'appDeployments',
마이그레이션: 'appMigrations',
'연결 상태': 'gatewayStatus',
'Agent 관리': 'gatewayAgents',
'장비 동기화': 'gatewayDevices',
'노드 현황': 'tailscaleStatus',
라우트: 'tailscaleRoutes',
ACL: 'tailscaleAcl',
'연결 진단': 'tailscaleDiagnostics',
'장비 등록': 'agentRegistration',
'설치 상태': 'agentInstallStatus',
'SSH 키 배포': 'agentSshKeys',
'Workspace 설정': 'agentWorkspace',
'원격 명령': 'agentCommands',
'로그/이벤트': 'runtimeLogs',
'장비 제어': 'runtimeDeviceControl',
'관리자 관리': 'adminManagement',
'사용자/세션': 'userSessions',
라이센스: 'licenses'
};
const pathByView: Record<ViewKey, string> = {
dashboard: '/dashboard',
launcherSources: '/launcher/sources',
launcherBuilds: '/launcher/builds',
launcherVersions: '/launcher/versions',
launcherComponents: '/launcher/components',
launcherApps: '/launcher/apps',
launcherCommonSettings: '/launcher/common-settings',
notices: '/operations/notices',
appManagement: '/applications/apps',
appSources: '/applications/sources',
appBuilds: '/applications/builds',
appReleases: '/applications/releases',
appDeployments: '/applications/deployments',
appMigrations: '/applications/migrations',
gatewayStatus: '/gateway/status',
gatewayAgents: '/gateway/agents',
gatewayDevices: '/gateway/devices',
tailscaleStatus: '/tailscale/status',
tailscaleRoutes: '/tailscale/routes',
tailscaleAcl: '/tailscale/acl',
tailscaleDiagnostics: '/tailscale/diagnostics',
agentRegistration: '/agents/registration',
agentInstallStatus: '/agents/install-status',
agentSshKeys: '/agents/ssh-keys',
agentWorkspace: '/agents/workspace',
agentCommands: '/agents/commands',
runtimeConfig: '/applications/runtime-settings',
runtimeLogs: '/runtime/logs',
runtimeDeviceControl: '/runtime/devices',
adminManagement: '/operations/admins',
userSessions: '/operations/users',
licenses: '/operations/licenses'
};
const viewByPath = Object.fromEntries(
Object.entries(pathByView).map(([view, path]) => [path, view as ViewKey])
) as Record<string, ViewKey>;
function viewFromPath(pathname: string): ViewKey {
if (pathname === '/') return 'dashboard';
return viewByPath[pathname] ?? 'dashboard';
}
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4500/api';
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222';
type GiteaRepo = {
id: number;
owner: string;
name: string;
fullName: string;
description?: string;
private: boolean;
htmlUrl: string;
cloneUrl: string;
sshUrl: string;
defaultBranch: string;
updatedAt: string;
language?: string;
branchCount?: number;
releaseCount?: number;
};
type CurrentAdmin = {
email: string;
name?: string;
role?: string;
};
function readCurrentAdmin(): CurrentAdmin | null {
try {
const raw = localStorage.getItem('studio-admin');
if (!raw) return null;
const admin = JSON.parse(raw) as CurrentAdmin;
return admin?.email ? admin : null;
} catch {
return null;
}
}
type ApiAppComponent = {
id: string;
appId: string;
name: string;
type: string;
repository?: string | null;
branch?: string | null;
buildTarget?: string | null;
app?: ApiApp;
};
type ApiApp = {
id: string;
key: string;
name: string;
description?: string | null;
namespace?: string | null;
status: string;
components?: ApiAppComponent[];
};
type ApiLauncherVersion = {
id: string;
version: string;
minimumVersion?: string | null;
repository?: string | null;
branch?: string | null;
commitSha?: string | null;
artifact: string;
downloadUrl: string;
releaseNotes?: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
};
type ApiAppRelease = {
id: string;
appId: string;
version: string;
branch: string;
artifact?: string | null;
status: string;
createdAt: string;
updatedAt?: string | null;
app?: ApiApp;
};
type LicensePlanSummary = {
id: string;
code: string;
name: string;
assignedCount: number;
activeUserCount: number;
expiringSoonCount: number;
};
type CommonAppPipeline = {
id: string;
repository: string;
message: string;
branch: string;
status: string;
createdAt: number;
url?: string | null;
};
type ApiNotice = {
id: string;
appKey: string;
title: string;
body: string;
linkUrl?: string | null;
isActive: boolean;
startsAt?: string | null;
endsAt?: string | null;
priority?: number;
status?: string;
createdAt: string;
updatedAt?: string | null;
};
type RuntimeResource = {
kind: string;
name: string;
status: string;
ready: string;
restarts: number | null;
age: string;
detail: string;
};
type RuntimeResourceResponse = {
namespace: string;
exists: boolean;
summary: {
pods: number;
deployments: number;
statefulSets: number;
services: number;
persistentVolumeClaims: number;
};
resources: RuntimeResource[];
};
type RuntimePodMetric = {
namespace: string;
pod: string;
cpu: string;
memory: string;
};
type DashboardRuntimeResource = RuntimeResource & {
namespace: string;
appName: string;
cpu?: string;
memory?: string;
};
type UserSessionRow = {
id: string;
no: number;
sessionId: string | null;
userId: string;
user: string;
email: string;
role: string;
session: string;
createdAt: string | null;
expiresAt: string | null;
lastSeen: string | null;
state: string;
};
type AdminRow = {
id: string;
email: string;
name: string;
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER';
isActive: boolean;
lastLoginAt: string | null;
createdAt: string;
updatedAt: string;
sessions?: Array<{
id: string;
expiresAt: string | null;
revokedAt: string | null;
createdAt: string;
}>;
sshKeys?: Array<{
id: string;
label?: string | null;
fingerprint?: string | null;
}>;
};
const statusTone = {
success: 'green',
succeeded: 'green',
done: 'green',
running: 'blue',
started: 'blue',
pending: 'amber',
blocked: 'amber',
created: 'amber',
failure: 'red',
failed: 'red',
error: 'red',
killed: 'red',
활성: 'green',
'실행 중': 'green',
게시됨: 'green',
카나리: 'blue',
예약됨: 'blue',
점검: 'amber',
주의: 'amber',
드레이닝: 'amber',
준비중: 'amber',
배포됨: 'green',
초안: 'neutral',
대기: 'neutral',
만료: 'neutral',
종료: 'neutral',
'세션 없음': 'neutral',
비활성: 'neutral',
'지원 종료': 'red',
잠김: 'red',
구성완료: 'green',
검토필요: 'amber',
완료: 'green'
} as const;
function getTone(status: string) {
return statusTone[status as keyof typeof statusTone] ?? 'neutral';
}
function formatRelativeTime(timestamp: number) {
if (!timestamp) return '-';
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds < 60) return '방금 전';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}분 전`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}일 전`;
return new Intl.DateTimeFormat('ko-KR', { month: 'short', day: 'numeric' }).format(new Date(timestamp));
}
type NavGroup = (typeof navGroups)[number];
type NavItem = NavGroup['items'][number];
type NavChild = { label: string; view?: string };
function resolveNavView(item: NavItem | NavChild): ViewKey | undefined {
return 'view' in item && item.view ? item.view as ViewKey : viewByLabel[item.label];
}
function isOwnerRole(role?: string | null) {
return role === 'OWNER';
}
function isViewerRole(role?: string | null) {
return role === 'VIEWER';
}
function filterNavGroupsByRole(groups: typeof navGroups, role?: string | null) {
const isOwner = isOwnerRole(role);
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => isOwner || item.label !== '관리자 관리')
}))
.filter((group) => group.items.length > 0);
}
function Sidebar({
activeView,
menuSearch,
currentAdmin,
onViewChange
}: {
activeView: ViewKey;
menuSearch: string;
currentAdmin: CurrentAdmin | null;
onViewChange: (view: ViewKey) => void;
}) {
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
const normalizedMenuSearch = menuSearch.trim().toLowerCase();
const roleNavGroups = filterNavGroupsByRole(navGroups, currentAdmin?.role) as typeof navGroups;
const visibleGroups = normalizedMenuSearch
? roleNavGroups
.map((group) => {
const groupMatches = group.label.toLowerCase().includes(normalizedMenuSearch);
const items = groupMatches
? group.items
: group.items.flatMap((item) => {
const itemMatches = item.label.toLowerCase().includes(normalizedMenuSearch);
if ('children' in item && item.children) {
const matchedChildren = item.children.filter((child) => child.label.toLowerCase().includes(normalizedMenuSearch));
if (itemMatches) return [item];
if (matchedChildren.length) return [{ ...item, children: matchedChildren } as NavItem];
return [];
}
return itemMatches ? [item] : [];
});
return { ...group, items };
})
.filter((group) => group.items.length > 0)
: roleNavGroups;
return (
<aside className="fixed inset-y-0 left-0 hidden w-[17rem] flex-col border-r border-border bg-sidebar px-3 py-4 lg:flex">
<div className="mb-8 flex shrink-0 justify-center px-4 py-3">
<img className="h-5 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
</div>
<nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
{visibleGroups.map((group) => (
<div key={group.label}>
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
<div className="space-y-1">
{group.items.map((item) => {
const hasChildren = 'children' in item && item.children;
const isParentActive = hasChildren
? item.children.some((child) => resolveNavView(child) === activeView)
: activeView === resolveNavView(item);
const isMenuOpen = Boolean(normalizedMenuSearch) || (openMenus[item.label] ?? isParentActive);
return (
<div key={item.label}>
<button
type="button"
onClick={() => {
if (hasChildren) {
setOpenMenus((prev) => ({ ...prev, [item.label]: !prev[item.label] }));
return;
}
const view = resolveNavView(item);
if (view) onViewChange(view);
}}
className={cn(
'flex h-9 w-full items-center gap-2 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
isParentActive && 'font-semibold text-white'
)}
>
<item.icon className={cn('size-4', isParentActive && 'text-white')} />
<span className="min-w-0 flex-1 text-left">{item.label}</span>
{hasChildren ? (
isMenuOpen ? (
<ChevronDown className={cn('size-3.5 text-muted-foreground', isParentActive && 'text-white')} />
) : (
<ChevronRight className={cn('size-3.5 text-muted-foreground', isParentActive && 'text-white')} />
)
) : null}
</button>
{hasChildren && isMenuOpen ? (
<div className="mt-1 space-y-1 pl-6">
{item.children.map((child) => {
const isChildActive = activeView === resolveNavView(child);
return (
<button
type="button"
key={child.label}
onClick={() => {
const view = resolveNavView(child);
if (view) onViewChange(view);
}}
className={cn(
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[12.5px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
isChildActive && 'font-semibold text-white'
)}
>
<span className={cn('h-3 w-1 rounded-full', isChildActive ? 'bg-white' : 'bg-transparent')} />
<span>{child.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
</div>
))}
{visibleGroups.length === 0 ? (
<div className="px-2 py-6 text-center text-xs text-muted-foreground">검색된 메뉴가 없습니다.</div>
) : null}
</nav>
</aside>
);
}
function PageTitle({
eyebrow,
title,
description,
actions
}: {
eyebrow: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm text-muted-foreground">{eyebrow}</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight md:text-3xl">{title}</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
);
}
function LoadingModal({ open, label = '데이터를 불러오는 중입니다.' }: { open: boolean; label?: string }) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/45 px-4 backdrop-blur-[2px]" role="status" aria-live="polite">
<div className="flex min-w-56 flex-col items-center rounded-lg border border-border bg-card px-6 py-5 shadow-xl">
<div className="size-9 animate-spin rounded-full border-2 border-muted border-t-primary" />
<p className="mt-3 text-sm font-medium text-foreground">{label}</p>
</div>
</div>
);
}
function useMinimumLoading(loading: boolean, minimumMs = 500) {
const [visible, setVisible] = useState(loading);
const startedAtRef = useRef<number | null>(loading ? Date.now() : null);
useEffect(() => {
if (loading) {
startedAtRef.current = Date.now();
setVisible(true);
return;
}
if (!visible) return;
const elapsed = startedAtRef.current ? Date.now() - startedAtRef.current : minimumMs;
const remaining = Math.max(minimumMs - elapsed, 0);
const timer = window.setTimeout(() => {
startedAtRef.current = null;
setVisible(false);
}, remaining);
return () => window.clearTimeout(timer);
}, [loading, minimumMs, visible]);
return visible;
}
function Topbar({
menuSearch,
onMenuSearchChange,
currentAdmin,
onLogout
}: {
menuSearch: string;
onMenuSearchChange: (value: string) => void;
currentAdmin: CurrentAdmin | null;
onLogout: () => void;
}) {
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement | null>(null);
const accountLabel = currentAdmin?.email ?? '관리자';
const avatarLetter = accountLabel.trim().charAt(0).toUpperCase() || 'A';
useEffect(() => {
const closeProfile = (event: MouseEvent) => {
if (!profileRef.current?.contains(event.target as Node)) setProfileOpen(false);
};
document.addEventListener('mousedown', closeProfile);
return () => document.removeEventListener('mousedown', closeProfile);
}, []);
return (
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-border bg-background/90 px-4 backdrop-blur-xl md:px-6">
<div className="flex min-w-0 items-center gap-3">
<Button variant="outline" className="lg:hidden">
<SlidersHorizontal className="size-4" />
</Button>
<div className="relative hidden w-80 md:block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="메뉴 검색"
value={menuSearch}
onChange={(event) => onMenuSearchChange(event.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" className="size-9 px-0">
<CircleHelp className="size-4" />
</Button>
<div ref={profileRef} className="relative">
<Button variant="outline" className="gap-2" onClick={() => setProfileOpen((open) => !open)}>
<span className="flex size-6 items-center justify-center rounded bg-muted text-xs font-semibold">
{avatarLetter}
</span>
<span className="hidden max-w-48 truncate md:inline">{accountLabel}</span>
<ChevronDown className="size-4 text-muted-foreground" />
</Button>
{profileOpen ? (
<div className="absolute right-0 top-11 z-50 w-64 overflow-hidden rounded-md border border-border bg-card shadow-xl">
<div className="border-b border-border px-4 py-3">
<p className="truncate text-sm text-foreground">{currentAdmin?.name || accountLabel}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
</div>
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
onClick={() => {
setProfileOpen(false);
onLogout();
}}
>
<LogOut className="size-4" />
로그아웃
</button>
</div>
) : null}
</div>
</div>
</header>
);
}
function LoginPage({ onLogin }: { onLogin: (admin: CurrentAdmin) => void }) {
const savedEmail = localStorage.getItem('studio-admin-saved-email') ?? '';
const [email, setEmail] = useState(savedEmail);
const [password, setPassword] = useState('');
const [rememberEmail, setRememberEmail] = useState(Boolean(savedEmail));
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const submitLogin = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!email.trim() || !password.trim()) return;
setLoginLoading(true);
setLoginError(false);
fetch(`${apiBaseUrl}/auth/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password })
})
.then((response) => {
if (!response.ok) throw new Error('Admin login failed');
return response.json() as Promise<{ token: string; expiresAt: string; admin: { email: string; name: string; role: string } }>;
})
.then((data) => {
localStorage.setItem('studio-admin-token', data.token);
localStorage.setItem('studio-admin', JSON.stringify(data.admin));
if (rememberEmail) {
localStorage.setItem('studio-admin-saved-email', email.trim());
} else {
localStorage.removeItem('studio-admin-saved-email');
}
setLoginLoading(false);
onLogin(data.admin);
})
.catch(() => {
setLoginLoading(false);
setLoginError(true);
});
};
return (
<div className="min-h-screen bg-background px-4 text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-sm items-center justify-center py-12">
<div className="w-full rounded-lg border border-border bg-card/95 shadow-2xl">
<div className="px-6 pb-5 pt-8">
<div className="flex justify-center">
<img className="h-6 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
</div>
<div className="mt-7 text-center">
<h1 className="text-xl font-semibold tracking-tight">관리자 로그인</h1>
<p className="mt-2 text-sm text-muted-foreground">계정 정보를 입력해 주세요.</p>
</div>
</div>
<form className="space-y-5 px-6 py-8" autoComplete="off" onSubmit={submitLogin}>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">이메일</span>
<input
type="email"
autoComplete="off"
className="login-light-field mt-2 h-10 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff' }}
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">비밀번호</span>
<input
id="password"
name="password"
type="password"
autoComplete="off"
className="login-light-field login-password-input mt-2 h-10 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff' }}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{loginError ? (
<p className="text-center text-xs text-rose-300">이메일 또는 비밀번호를 확인해 주세요.</p>
) : null}
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
className="size-4 accent-primary"
checked={rememberEmail}
onChange={(event) => setRememberEmail(event.target.checked)}
/>
아이디 저장
</label>
<button
type="submit"
className="mt-5 h-10 w-full rounded-md text-sm font-bold transition-colors hover:bg-[#256fdb] disabled:pointer-events-none disabled:opacity-45"
style={{ backgroundColor: '#387ef0', color: '#ffffff', WebkitTextFillColor: '#ffffff' }}
disabled={!email.trim() || !password.trim() || loginLoading}
>
<span style={{ color: '#ffffff', WebkitTextFillColor: '#ffffff' }}>
{loginLoading ? '확인 중' : '로그인'}
</span>
</button>
</form>
</div>
</div>
</div>
);
}
function isActiveStatus(status?: string | null) {
return ['활성', 'ACTIVE', 'READY', 'RUNNING', 'SUCCEEDED'].includes(status ?? '');
}
function getLatestReleaseByApp(releases: ApiAppRelease[]) {
return releases.reduce<Record<string, ApiAppRelease>>((acc, release) => {
const current = acc[release.appId];
if (!current || new Date(release.createdAt).getTime() > new Date(current.createdAt).getTime()) {
acc[release.appId] = release;
}
return acc;
}, {});
}
function isRuntimeResourceHealthy(resource: DashboardRuntimeResource) {
if (['Error', 'CrashLoopBackOff', 'Failed'].includes(resource.status)) return false;
if (!resource.ready.includes('/')) return ['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(resource.status);
const [ready, total] = resource.ready.split('/').map((value) => Number(value));
return Number.isFinite(ready) && Number.isFinite(total) && total > 0 && ready === total;
}
function formatJobStatus(status?: string | null) {
const labels: Record<string, string> = {
PENDING: '대기',
RUNNING: '실행 중',
SUCCEEDED: '성공',
FAILED: '실패',
CANCELED: '취소'
};
return status ? labels[status] ?? status : '-';
}
function SkeletonBlock({ className }: { className?: string }) {
return (
<div
className={cn(
'relative overflow-hidden rounded-md bg-white/10',
'before:absolute before:inset-0 before:-translate-x-full before:animate-[skeleton-shimmer_1.35s_ease-in-out_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/25 before:to-transparent',
className
)}
/>
);
}
function MetricCards({
apps: dashboardApps,
sessions,
licensePlans,
runtimeResources,
loading
}: {
apps: ApiApp[];
sessions: UserSessionRow[];
licensePlans: LicensePlanSummary[];
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 healthyRuntimeCount = runtimeResources.filter(isRuntimeResourceHealthy).length;
const runtimeRate = runtimeResources.length ? Math.round((healthyRuntimeCount / runtimeResources.length) * 100) : null;
const cards = [
{
label: '활성 앱',
value: loading ? '-' : String(activeApps),
delta: `${dashboardApps.length}개 등록`,
hint: '앱 관리 등록 기준',
tone: 'green' as const
},
{
label: '접속 사용자',
value: loading ? '-' : String(activeSessions),
delta: `${sessions.length}개 세션`,
hint: '활성 사용자 세션 기준',
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 || runtimeRate === null ? '-' : `${runtimeRate}%`,
delta: runtimeResources.length ? `${healthyRuntimeCount}/${runtimeResources.length}` : '리소스 없음',
hint: 'namespace 리소스 Ready 기준',
tone: runtimeRate !== null && runtimeRate < 80 ? 'red' as const : 'green' as const
}
];
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{cards.map((metric) => (
<Card key={metric.label} className="p-5">
{loading ? (
<div>
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<SkeletonBlock className="h-5 w-20 rounded-md" />
</div>
<SkeletonBlock className="mt-5 h-8 w-20" />
<p className="mt-2 text-xs text-muted-foreground">{metric.hint}</p>
</div>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<Badge tone={metric.tone}>{metric.delta}</Badge>
</div>
<div className="mt-4 text-3xl font-semibold tracking-tight">{metric.value}</div>
<p className="mt-1 text-xs text-muted-foreground">{metric.hint}</p>
</>
)}
</Card>
))}
</div>
);
}
function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVersion[]; loading: boolean; onOpen: () => void }) {
const activeVersion = versions.find((version) => version.isActive) ?? versions[0];
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="런처 버전 관리"
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
action={<DashboardLinkButton onClick={onOpen} label="런처 버전 관리로 이동" />}
/>
<div className="p-5">
{loading ? (
<div className="min-h-48 rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 space-y-2">
<p className="font-medium">Studio Launcher</p>
<SkeletonBlock className="h-3 w-56" />
</div>
<SkeletonBlock className="h-5 w-14 rounded-md" />
</div>
<div className="mt-5 grid gap-3 text-sm">
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">배포 파일</p>
<SkeletonBlock className="mt-3 h-4 w-52" />
</div>
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<SkeletonBlock className="mt-3 h-4 w-full" />
</div>
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<SkeletonBlock className="mt-3 h-4 w-4/5" />
</div>
</div>
</div>
) : activeVersion ? (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">Studio Launcher</p>
<Badge tone={activeVersion.isActive ? 'green' : 'neutral'}>{activeVersion.isActive ? '활성' : '비활성'}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'}
</p>
</div>
</div>
<div className="mt-4 grid gap-3 text-sm">
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">배포 파일</p>
<p className="mt-1 break-all font-medium leading-5">{activeVersion.artifact}</p>
</div>
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<p className="mt-1 break-all font-medium leading-5">{activeVersion.downloadUrl}</p>
</div>
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<p className="mt-1 text-sm leading-6 text-foreground">{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}</p>
</div>
</div>
</div>
) : (
<div className="flex min-h-48 items-center justify-center rounded-md border border-border bg-muted/20 p-4 text-sm text-muted-foreground">
등록된 런처 버전이 없습니다.
</div>
)}
</div>
</Card>
);
}
function CommonAppsPanel({ pipelines, loading }: { pipelines: CommonAppPipeline[]; loading: boolean }) {
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader title="공통앱" description="공통앱의 최근 빌드 상태를 확인합니다." />
<div className="max-h-[420px] overflow-y-auto">
{loading ? (
<div className="space-y-3 p-5">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-md border border-border bg-muted/20 p-3">
<SkeletonBlock className="h-4 w-48" />
<SkeletonBlock className="mt-3 h-3 w-64" />
</div>
))}
</div>
) : pipelines.length === 0 ? (
<div className="flex min-h-36 items-center justify-center px-5 text-sm text-muted-foreground">표시할 저장소가 없습니다.</div>
) : (
<div className="divide-y divide-border">
{pipelines.slice(0, 8).map((pipeline) => (
<div key={pipeline.id} className="px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold">{pipeline.repository}</p>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">{pipeline.message}</p>
</div>
{pipeline.url ? (
<a href={pipeline.url} target="_blank" rel="noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Woodpecker 저장소 열기">
<ExternalLink className="size-4" />
</a>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge tone={getTone(pipeline.status)}>{pipeline.status}</Badge>
<span>{pipeline.branch}</span>
<span>{formatRelativeTime(pipeline.createdAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
</Card>
);
}
function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow[]; loading: boolean; onMore: () => void }) {
return (
<Card>
<CardHeader
title="사용자/세션"
description="실시간 접근 상태와 세션 제어 항목입니다."
action={
<button
type="button"
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onMore}
title="사용자/세션으로 이동"
aria-label="사용자/세션으로 이동"
>
<ChevronRight className="size-4" />
</button>
}
/>
<div className="divide-y divide-border">
{loading ? (
Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center justify-between gap-3 px-5 py-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center gap-2">
<SkeletonBlock className="h-4 w-28" />
<SkeletonBlock className="h-5 w-12 rounded-md" />
</div>
<SkeletonBlock className="h-3 w-44" />
</div>
<SkeletonBlock className="h-3 w-20 shrink-0" />
</div>
))
) : sessions.length === 0 ? (
<div className="px-5 py-6 text-center text-sm text-muted-foreground">표시할 사용자 세션이 없습니다.</div>
) : null}
{!loading && sessions.map((session) => (
<div key={session.id} className="flex items-center justify-between gap-3 px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{session.user}</p>
<Badge tone={getTone(session.state)}>{session.state}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{session.email} · {session.role}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{formatDateTime(session.lastSeen)}</span>
</div>
))}
</div>
</Card>
);
}
function DashboardLinkButton({ onClick, label }: { onClick: () => void; label: string }) {
return (
<button
type="button"
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onClick}
title={label}
aria-label={label}
>
<ChevronRight className="size-4" />
</button>
);
}
function AppRegistryTable({
apps: dashboardApps,
releases,
loading,
onOpen
}: {
apps: ApiApp[];
releases: ApiAppRelease[];
loading: boolean;
onOpen: () => void;
}) {
const latestReleaseByApp = getLatestReleaseByApp(releases);
return (
<Card>
<CardHeader
title="앱 관리"
description="등록된 앱 그룹과 구성요소, 최신 릴리즈 상태를 확인합니다."
action={<DashboardLinkButton onClick={onOpen} label="앱 관리로 이동" />}
/>
<div className="max-h-[444px] overflow-auto">
<table className="w-full min-w-[980px] 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">상태</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>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
Array.from({ length: 5 }).map((_, index) => (
<tr key={index}>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-32" />
<SkeletonBlock className="mt-2 h-3 w-44" />
</td>
<td className="px-5 py-3">
<SkeletonBlock className="h-5 w-14 rounded-md" />
</td>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-24" />
</td>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-52" />
</td>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-8" />
</td>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-8" />
</td>
</tr>
))
) : dashboardApps.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-12 text-center text-muted-foreground">등록된 앱이 없습니다.</td>
</tr>
) : null}
{!loading && dashboardApps.map((app) => {
const latestRelease = latestReleaseByApp[app.id];
const firstRepository = app.components?.find((component) => component.repository)?.repository;
const workloadCount = app.components?.filter((component) => ['CONTAINER', 'DATABASE', 'MIGRATION'].includes(component.type)).length ?? 0;
return (
<tr key={app.id} className="hover:bg-muted/20">
<td className="px-5 py-3">
<p className="font-medium">{app.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{app.key} · {app.namespace ?? '네임스페이스 없음'}</p>
</td>
<td className="px-5 py-3">
<Badge tone={getTone(app.status)}>{app.status}</Badge>
</td>
<td className="px-5 py-3 text-muted-foreground">{latestRelease ? `${latestRelease.version} · ${formatJobStatus(latestRelease.status)}` : '-'}</td>
<td className="px-5 py-3">
<span className="inline-flex max-w-64 items-center gap-1 truncate text-muted-foreground">
{firstRepository ?? '-'}
{firstRepository ? <ExternalLink className="size-3 shrink-0" /> : null}
</span>
</td>
<td className="px-5 py-3">{workloadCount}</td>
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
}
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
return (
<Card>
<CardHeader
title="런타임 상태"
description="앱 네임스페이스에서 조회한 주요 리소스 상태입니다."
action={<DashboardLinkButton onClick={onOpen} label="로그/이벤트로 이동" />}
/>
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
{loading ? (
Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center gap-2">
<SkeletonBlock className="h-4 w-44" />
<SkeletonBlock className="h-5 w-16 rounded-md" />
</div>
<SkeletonBlock className="h-3 w-52" />
</div>
<SkeletonBlock className="size-4 shrink-0 rounded-full" />
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div>
<div className="mb-2 flex justify-between">
<SkeletonBlock className="h-3 w-10" />
<SkeletonBlock className="h-3 w-8" />
</div>
<SkeletonBlock className="h-2 w-full rounded-full" />
</div>
<div>
<div className="mb-2 flex justify-between">
<SkeletonBlock className="h-3 w-12" />
<SkeletonBlock className="h-3 w-8" />
</div>
<SkeletonBlock className="h-2 w-full rounded-full" />
</div>
</div>
</div>
))
) : resources.length === 0 ? (
<div className="px-5 py-12 text-center text-sm text-muted-foreground">표시할 런타임 리소스가 없습니다.</div>
) : null}
{!loading && resources.slice(0, 8).map((item) => (
<div key={`${item.namespace}-${item.kind}-${item.name}`} className="px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{item.kind}/{item.name}</p>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">{item.appName} · {item.namespace}</p>
</div>
<Activity className="size-4 shrink-0 text-muted-foreground" />
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div>
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
<span>{item.cpu ? 'CPU' : 'Ready'}</span>
<span>{item.cpu || item.ready || '-'}</span>
</div>
<Progress value={isRuntimeResourceHealthy(item) ? 100 : 30} tone={isRuntimeResourceHealthy(item) ? 'green' : 'amber'} />
</div>
<div>
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
<span>{item.memory ? '메모리' : '재시작'}</span>
<span>{item.memory || (item.restarts ?? '-')}</span>
</div>
<Progress value={Math.min((item.restarts ?? 0) * 20, 100)} tone={(item.restarts ?? 0) > 0 ? 'amber' : 'primary'} />
</div>
</div>
</div>
))}
</div>
</Card>
);
}
function NoticePanel({ notices: dashboardNotices, loading, onOpen }: { notices: ApiNotice[]; loading: boolean; onOpen: () => void }) {
return (
<Card>
<CardHeader
title="공지사항"
description="런처 공지와 앱별 공지사항을 관리합니다."
action={<DashboardLinkButton onClick={onOpen} label="공지사항으로 이동" />}
/>
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
{loading ? (
Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center justify-between gap-3 px-5 py-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<SkeletonBlock className="h-5 w-16 rounded-md" />
<SkeletonBlock className="h-5 w-14 rounded-md" />
</div>
<SkeletonBlock className="mt-3 h-4 w-56" />
<SkeletonBlock className="mt-2 h-3 w-72 max-w-full" />
</div>
<SkeletonBlock className="h-3 w-20 shrink-0" />
</div>
))
) : dashboardNotices.length === 0 ? (
<div className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 공지사항이 없습니다.</div>
) : null}
{!loading && dashboardNotices.slice(0, 8).map((notice) => (
<div key={notice.id} className="flex items-center justify-between gap-3 px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge tone={notice.appKey === 'Launcher' ? 'violet' : 'blue'}>{notice.appKey}</Badge>
<Badge tone={notice.isActive ? 'green' : 'neutral'}>{notice.isActive ? '게시' : '비게시'}</Badge>
</div>
<p className="mt-2 truncate text-sm font-medium">{notice.title}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{notice.linkUrl || notice.body}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{formatDateTime(notice.createdAt)}</span>
</div>
))}
</div>
</Card>
);
}
function LauncherManagementPage() {
const [launcherRows, setLauncherRows] = useState<ApiLauncherVersion[]>([]);
const [launcherRepos, setLauncherRepos] = useState<GiteaRepo[]>([]);
const [launcherLoading, setLauncherLoading] = useState(true);
const launcherLoadingVisible = useMinimumLoading(launcherLoading);
const [launcherError, setLauncherError] = useState(false);
const [sourceModalOpen, setSourceModalOpen] = useState(false);
const [sourceRepository, setSourceRepository] = useState('');
const [sourceBranch, setSourceBranch] = useState('release');
const [sourceCommitSha, setSourceCommitSha] = useState('');
const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
const [releaseSaveStatus, setReleaseSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [newLauncherRelease, setNewLauncherRelease] = useState({
version: '',
minimumVersion: '',
artifact: '',
downloadUrl: '',
releaseNotes: '',
repository: '',
branch: 'release',
commitSha: ''
});
const launcher = launcherRows[0];
const refreshLauncherVersions = () => {
setLauncherLoading(true);
loadLauncherVersions()
.then((versions) => {
setLauncherRows(versions);
setLauncherError(false);
setLauncherLoading(false);
})
.catch(() => {
setLauncherRows([]);
setLauncherError(true);
setLauncherLoading(false);
});
};
useEffect(() => {
let mounted = true;
refreshLauncherVersions();
loadPlatformRepos()
.then((response) => {
if (!response.ok) throw new Error('Gitea repositories request failed');
return response.json() as Promise<{ repos: GiteaRepo[] }>;
})
.then((data) => {
if (!mounted) return;
setLauncherRepos(data.repos ?? []);
})
.catch(() => {
if (!mounted) return;
setLauncherRepos([]);
});
return () => {
mounted = false;
};
}, []);
const openSourceModal = () => {
if (!launcher) return;
setSourceRepository(launcher.repository ?? launcherRepos[0]?.fullName ?? '');
setSourceBranch(launcher.branch ?? 'release');
setSourceCommitSha(launcher.commitSha ?? '');
setSourceSaveStatus('idle');
setSourceModalOpen(true);
};
const saveLauncherSource = () => {
if (!launcher || !sourceRepository.trim()) return;
setSourceSaveStatus('saving');
apiFetch(`${apiBaseUrl}/launcher/versions/${launcher.id}/source`, {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
repository: sourceRepository.trim(),
branch: sourceBranch.trim() || undefined,
commitSha: sourceCommitSha.trim() || undefined
})
})
.then((response) => {
if (!response.ok) throw new Error('Launcher source link failed');
return response.json();
})
.then(() => {
setSourceModalOpen(false);
setSourceSaveStatus('idle');
refreshLauncherVersions();
})
.catch(() => setSourceSaveStatus('error'));
};
const openReleaseModal = () => {
setNewLauncherRelease({
version: '',
minimumVersion: launcher?.minimumVersion ?? '',
artifact: '',
downloadUrl: '',
releaseNotes: '',
repository: launcher?.repository ?? launcherRepos[0]?.fullName ?? '',
branch: launcher?.branch ?? 'release',
commitSha: ''
});
setReleaseSaveStatus('idle');
setReleaseModalOpen(true);
};
const createLauncherRelease = () => {
if (!newLauncherRelease.version.trim() || !newLauncherRelease.artifact.trim() || !newLauncherRelease.downloadUrl.trim()) return;
setReleaseSaveStatus('saving');
apiFetch(`${apiBaseUrl}/launcher/versions`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
version: newLauncherRelease.version.trim(),
minimumVersion: newLauncherRelease.minimumVersion.trim() || undefined,
artifact: newLauncherRelease.artifact.trim(),
downloadUrl: newLauncherRelease.downloadUrl.trim(),
releaseNotes: newLauncherRelease.releaseNotes.trim() || undefined,
repository: newLauncherRelease.repository || undefined,
branch: newLauncherRelease.branch || undefined,
commitSha: newLauncherRelease.commitSha.trim() || undefined
})
})
.then((response) => {
if (!response.ok) throw new Error('Launcher release create failed');
return response.json();
})
.then(() => {
setReleaseModalOpen(false);
setReleaseSaveStatus('idle');
refreshLauncherVersions();
})
.catch(() => setReleaseSaveStatus('error'));
};
return (
<>
<PageTitle
eyebrow="런처 운영"
title="버전 관리"
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태만 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={openSourceModal} disabled={!launcher}>
<Settings2 className="size-4" />
소스 연결
</Button>
<Button className="h-8 px-3 text-[13px]" onClick={openReleaseModal}>
<Plus className="size-4" />릴리즈 등록
</Button>
</>
}
/>
{launcherError ? (
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
런처 버전 데이터를 불러오지 못했습니다.
</div>
) : null}
{!launcherLoading && !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">Autonomy Studio Launcher</p>
<Badge tone={launcher.isActive ? 'green' : 'neutral'}>{launcher.isActive ? '활성' : '비활성'}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
최신 {launcher.version} · 최소 지원 {launcher.minimumVersion || '-'}
</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={openSourceModal}>변경</Button>
</div>
{launcher.repository ? (
<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">{launcher.repository}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Branch</p>
<p className="mt-1 truncate font-semibold">{launcher.branch || '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Commit</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{launcher.commitSha || '-'}</p>
</div>
<div className="md:col-span-3">
<p className="text-xs text-muted-foreground">Clone URL</p>
<div className="mt-1 flex items-center gap-2">
<p className="truncate font-mono text-xs text-muted-foreground">{getHttpCloneUrl(launcher.repository)}</p>
<button
type="button"
title="HTTP clone URL 복사"
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => copyToClipboard(getHttpCloneUrl(launcher.repository!))}
>
<Copy className="size-3.5" />
</button>
</div>
</div>
</div>
) : (
<p className="mt-3 text-sm text-muted-foreground">연결된 Gitea 저장소가 없습니다.</p>
)}
</div>
<div className="mt-4 rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<p className="mt-2 text-sm leading-6">{launcher.releaseNotes || '-'}</p>
</div>
</div>
</Card>
<Card>
<CardHeader title="업데이트 기준" description="런처 버전 판정에 필요한 최소 기준입니다." />
<div className="grid gap-3 p-5">
{[
['최신 버전', launcher.version],
['최소 런처 버전', launcher.minimumVersion || '-']
].map(([label, value]) => (
<div key={label} className="flex items-center justify-between gap-4 rounded-md border border-border bg-muted/20 px-4 py-3">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-right text-sm font-semibold">{value}</span>
</div>
))}
</div>
</Card>
</div>
) : null}
{sourceModalOpen && launcher ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold">런처 소스 연결</h2>
</div>
<div className="space-y-4 px-5 py-5">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Repository</span>
<select
value={sourceRepository}
onChange={(event) => setSourceRepository(event.target.value)}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
>
{launcherRepos.length === 0 ? <option value="">platform 저장소 없음</option> : null}
{launcherRepos.map((repo) => (
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Branch</span>
<select
value={sourceBranch}
onChange={(event) => setSourceBranch(event.target.value)}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
>
<option value="release">release</option>
<option value="develop">develop</option>
<option value="main">main</option>
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
<input
value={sourceCommitSha}
onChange={(event) => setSourceCommitSha(event.target.value)}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
{sourceSaveStatus === 'error' ? <p className="text-xs text-rose-300">런처 소스 연결 저장에 실패했습니다.</p> : null}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={() => setSourceModalOpen(false)}>취소</Button>
<Button onClick={saveLauncherSource} disabled={!sourceRepository || sourceSaveStatus === 'saving'}>
{sourceSaveStatus === 'saving' ? '저장 중' : '저장'}
</Button>
</div>
</div>
</div>
) : null}
{releaseModalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-2xl rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold">릴리즈 등록</h2>
</div>
<div className="grid max-h-[72vh] gap-4 overflow-y-auto px-5 py-5 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">버전</span>
<input
value={newLauncherRelease.version}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, version: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">최소 런처 버전</span>
<input
value={newLauncherRelease.minimumVersion}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, minimumVersion: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block md:col-span-2">
<span className="text-xs font-medium text-muted-foreground">배포 파일</span>
<input
value={newLauncherRelease.artifact}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, artifact: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block md:col-span-2">
<span className="text-xs font-medium text-muted-foreground">다운로드 URL</span>
<input
value={newLauncherRelease.downloadUrl}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, downloadUrl: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Repository</span>
<select
value={newLauncherRelease.repository}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, repository: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
>
<option value="">미연결</option>
{launcherRepos.map((repo) => (
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Branch</span>
<select
value={newLauncherRelease.branch}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, branch: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
>
<option value="release">release</option>
<option value="develop">develop</option>
<option value="main">main</option>
</select>
</label>
<label className="block md:col-span-2">
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
<input
value={newLauncherRelease.commitSha}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, commitSha: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block md:col-span-2">
<span className="text-xs font-medium text-muted-foreground">릴리즈 노트</span>
<textarea
value={newLauncherRelease.releaseNotes}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, releaseNotes: event.target.value }))}
className="mt-2 min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
/>
</label>
{releaseSaveStatus === 'error' ? (
<p className="md:col-span-2 text-xs text-rose-300">릴리즈 등록에 실패했습니다. 버전 중복과 필수값을 확인하세요.</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={() => setReleaseModalOpen(false)}>취소</Button>
<Button
onClick={createLauncherRelease}
disabled={
!newLauncherRelease.version.trim() ||
!newLauncherRelease.artifact.trim() ||
!newLauncherRelease.downloadUrl.trim() ||
releaseSaveStatus === 'saving'
}
>
{releaseSaveStatus === 'saving' ? '등록 중' : '등록'}
</Button>
</div>
</div>
</div>
) : null}
<LoadingModal open={launcherLoadingVisible} />
</>
);
}
function ContainersPage() {
const [selectedAppName, setSelectedAppName] = useState(containerBlueprints[0]?.app ?? '');
const selectedBlueprint = containerBlueprints.find((item) => item.app === selectedAppName) ?? containerBlueprints[0];
const totalResources = selectedBlueprint?.resources.length ?? 0;
const secretCount = selectedBlueprint?.resources.reduce((sum, resource) => sum + resource.secrets, 0) ?? 0;
const envCount = selectedBlueprint?.resources.reduce((sum, resource) => sum + resource.env, 0) ?? 0;
const runtimeRows = selectedBlueprint
? k3sWorkloads.filter((workload) => workload.app === selectedBlueprint.app)
: [];
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="실행 설정"
description="앱 실행에 필요한 서버 구성, 접속 경로, 설정값, 저장소를 한 묶음으로 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]">
<Settings2 className="size-4" />
템플릿
</Button>
<Button className="h-8 px-3 text-[13px]">
<Plus className="size-4" />
리소스 추가
</Button>
</>
}
/>
<div className="space-y-4">
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold">앱 선택</p>
<p className="mt-1 text-xs text-muted-foreground">실행 설정을 확인할 앱 그룹을 선택합니다.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
value={selectedAppName}
onChange={(event) => setSelectedAppName(event.target.value)}
>
{containerBlueprints.map((blueprint) => (
<option key={blueprint.app} value={blueprint.app}>
{blueprint.app}
</option>
))}
</select>
{selectedBlueprint ? (
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1">{selectedBlueprint.namespace}</span>
<span className="rounded-md bg-muted px-2 py-1">{selectedBlueprint.release}</span>
<span className="rounded-md bg-muted px-2 py-1">리소스 {selectedBlueprint.resources.length}</span>
</div>
) : null}
</div>
</div>
</Card>
{selectedBlueprint ? (
<div className="min-w-0 space-y-4">
<div className="grid gap-4 md:grid-cols-4">
{[
['네임스페이스', selectedBlueprint.namespace],
['리소스', `${totalResources}개`],
['환경변수', `${envCount}개`],
['시크릿', `${secretCount}개`]
].map(([label, value]) => (
<Card key={label} className="p-5">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-3 truncate text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="리소스 구성"
description="현재 실행 중인 워크로드와 주요 제어 기능입니다."
action={
<span className="text-xs font-semibold text-muted-foreground">
총 {runtimeRows.length || totalResources}개
</span>
}
/>
<div className="border-b border-border px-5 py-4">
<div className="grid gap-3 lg:grid-cols-[220px_180px_minmax(0,1fr)]">
<label className="space-y-2 text-xs font-medium text-muted-foreground">
네임스페이스
<select className="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={selectedBlueprint.namespace}>
<option value={selectedBlueprint.namespace}>{selectedBlueprint.namespace}</option>
</select>
</label>
<label className="space-y-2 text-xs font-medium text-muted-foreground">
상태
<select className="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="">
<option value="">전체</option>
<option value="실행 중">실행 중</option>
<option value="주의">주의</option>
</select>
</label>
<label className="space-y-2 text-xs font-medium text-muted-foreground">
검색
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="리소스명 / 워크로드 / 노드 / 이미지"
/>
</div>
</label>
</div>
</div>
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[1180px] 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-12 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">재시작</th>
<th className="px-5 py-3 font-medium">Node</th>
<th className="px-5 py-3 font-medium">Age</th>
<th className="px-5 py-3 text-right font-medium">제어</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{(runtimeRows.length ? runtimeRows : selectedBlueprint.resources.map((resource) => ({
namespace: selectedBlueprint.namespace,
app: selectedBlueprint.app,
workload: `${resource.kind}/${resource.name}`,
pod: resource.name,
image: resource.image,
replicas: String(resource.replicas),
node: '-',
restarts: 0,
age: '-',
status: resource.status
}))).map((resource) => (
<tr key={`${resource.namespace}-${resource.pod}`} className="hover:bg-muted/20">
<td className="px-5 py-3">
<span
className={cn(
'block size-2.5 rounded-full',
resource.status === '실행 중' || resource.status === '구성완료'
? 'bg-emerald-400'
: resource.status === '주의'
? 'bg-amber-400'
: 'bg-muted-foreground'
)}
/>
</td>
<td className="px-5 py-3">
<p className="font-semibold">{resource.pod}</p>
<p className="mt-1 text-xs text-muted-foreground">{resource.image}</p>
</td>
<td className="px-5 py-3">
<p className="font-semibold">{resource.workload}</p>
<p className="mt-1 text-xs text-muted-foreground">{resource.namespace}</p>
</td>
<td className="px-5 py-3">{resource.replicas}</td>
<td className="px-5 py-3">{resource.restarts}</td>
<td className="px-5 py-3 text-muted-foreground">{resource.node}</td>
<td className="px-5 py-3 text-muted-foreground">{resource.age}</td>
<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="secondary" className="h-8 px-3 text-[13px]">종료</Button>
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="grid gap-4 xl:grid-cols-3">
{[
['Service / Ingress', '외부 노출, 라우팅, TLS 설정'],
['ConfigMap / Env', '앱별 환경변수와 런타임 설정'],
['Secret / Storage', '민감정보와 PVC 볼륨 연결']
].map(([title, description]) => (
<Card key={title} className="p-5">
<p className="text-sm font-semibold">{title}</p>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
<Button variant="outline" className="mt-4 h-8 px-3 text-[13px]">
설정
</Button>
</Card>
))}
</div>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="최근 제어 작업" description="워크로드 재기동, 스케일 변경, 롤백 요청 이력입니다." />
<div className="divide-y divide-border">
{[
['LMS', 'lms-api', '재기동', 'Deployment/lms-api rollout restart', '완료', '2분 전'],
['LMS', 'lms-adm-web', '로그 조회', '최근 200 lines', '완료', '6분 전'],
['ADS IDE', 'ads-ide-api', '스케일 변경', 'replicas 1 -> 2', '실행 중', '9분 전'],
['LMS', 'lms-postgres', '이벤트 조회', 'StatefulSet/lms-postgres', '완료', '18분 전']
].map(([app, resource, action, detail, status, time], index) => (
<div key={`${app}-${resource}-${action}-${time}`} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 w-5 shrink-0 text-sm font-semibold text-muted-foreground">{index + 1}</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{action} · {resource}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{app} · {detail}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="text-xs text-muted-foreground">{time}</span>
<Badge tone={getTone(status)}>{status}</Badge>
</div>
</div>
))}
</div>
</Card>
</div>
) : null}
</div>
</>
);
}
function LauncherAppsPage() {
const linkedApps = apps
.filter((app) => app.launcherLinked)
.sort((a, b) => (a.launcherOrder ?? 999) - (b.launcherOrder ?? 999));
const unlinkedApps = apps.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">
{linkedApps.map((app) => (
<tr key={app.key} className="hover:bg-muted/20">
<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} 위로 이동`}>
<ChevronDown className="size-4 rotate-180" />
</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">
{unlinkedApps.map((app) => (
<div key={app.key} className="flex items-center justify-between gap-4 px-5 py-4">
<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>
</>
);
}
function GatewayStatusPage() {
return (
<>
<PageTitle eyebrow="플랫폼 게이트웨이" title="연결 상태" description="Headscale, Agent, 저장소, 런타임 명령 버스의 연결 상태를 확인합니다." />
<div className="grid gap-4 xl:grid-cols-2">
{gatewayConnections.map((item) => (
<Card key={item.name} className="p-5">
<div className="flex items-center justify-between gap-4">
<p className="truncate text-sm font-semibold">{item.name}</p>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
<p className="mt-2 text-xs text-muted-foreground">{item.role}</p>
<p className="mt-4 truncate text-sm font-medium">{item.endpoint}</p>
<p className="mt-1 text-xs text-muted-foreground">마지막 동기화 {item.lastSync}</p>
</Card>
))}
</div>
</>
);
}
function GatewayAgentsPage() {
return (
<>
<PageTitle eyebrow="플랫폼 게이트웨이" title="Agent 관리" description="플랫폼 게이트웨이가 수집한 Agent 등록 및 상태 정보를 관리합니다." />
<AgentDeviceTable title="Agent 목록" description="등록된 운영 장비 Agent입니다." />
</>
);
}
function GatewayDeviceSyncPage() {
return (
<>
<PageTitle eyebrow="플랫폼 게이트웨이" title="장비 동기화" description="계정, 장비, 워크스페이스 정보를 인증 서버와 Agent 상태 기준으로 동기화합니다." />
<div className="grid gap-4 xl:grid-cols-3">
{[
['계정/장비 매핑', 'Auth Backend -> Gateway', '활성'],
['워크스페이스 경로', 'Agent -> Gateway', '활성'],
['원격 실행 상태', 'Runtime -> Gateway', '주의']
].map(([title, flow, status]) => (
<Card key={title} className="p-5">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-semibold">{title}</p>
<Badge tone={getTone(status)}>{status}</Badge>
</div>
<p className="mt-3 text-xs text-muted-foreground">{flow}</p>
</Card>
))}
</div>
</>
);
}
function AgentDeviceTable({ title, description }: { title: string; description: string }) {
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader title={title} description={description} />
<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">장비</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>
</tr>
</thead>
<tbody className="divide-y divide-border">
{agentDevices.map((device) => (
<tr key={device.name} className="hover:bg-muted/20">
<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"><Badge tone={getTone(device.status)}>{device.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
function AgentRegistrationPage() {
return (
<>
<PageTitle eyebrow="Agent" title="장비 등록" description="플랫폼 장비와 계정 정보를 등록하고 Agent 연결을 준비합니다." />
<AgentDeviceTable title="등록 장비" description="Agent 설치 대상 장비입니다." />
</>
);
}
function AgentInstallStatusPage() {
return (
<>
<PageTitle eyebrow="Agent" title="설치 상태" description="Agent 설치, heartbeat, 상태 보고 현황을 확인합니다." />
<AgentDeviceTable title="설치 상태" description="장비별 Agent 상태입니다." />
</>
);
}
function AgentSshKeysPage() {
return (
<>
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="Agent가 장비에 배포한 SSH 키와 배포 상태를 관리합니다." />
<AgentDeviceTable title="SSH 키 상태" description="장비별 SSH 키 배포 상태입니다." />
</>
);
}
function AgentWorkspacePage() {
return (
<>
<PageTitle eyebrow="Agent" title="Workspace 설정" description="원격 실행과 저장소 연동에 사용할 장비별 워크스페이스 루트를 관리합니다." />
<AgentDeviceTable title="Workspace" description="장비별 작업 디렉터리 설정입니다." />
</>
);
}
function AgentCommandsPage() {
return (
<>
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 장비에 전달된 원격 명령과 실행 결과를 관리합니다." />
<Card>
<CardHeader title="명령 이력" description="최근 원격 명령 요청입니다." />
<div className="divide-y divide-border">
{runtimeCommands.map((item) => (
<div key={`${item.target}-${item.command}`} className="flex items-center justify-between gap-4 px-5 py-4">
<div>
<p className="text-sm font-semibold">{item.command}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.target} · {item.requestedBy} · {item.time}</p>
</div>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
))}
</div>
</Card>
</>
);
}
function LauncherCommonSettingsPage() {
const [apiModalOpen, setApiModalOpen] = useState(false);
return (
<>
<PageTitle
eyebrow="런처 운영"
title="공통 설정"
description="런처에서 공통으로 사용하는 연결값과 기본 설정을 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setApiModalOpen(true)}>
<ExternalLink className="size-4" />
공통 인증 API 보기
</Button>
<Button className="h-8 px-3 text-[13px]">
<Plus className="size-4" />
설정 추가
</Button>
</>
}
/>
<div className="grid gap-4 md:grid-cols-4">
{[
['설정 항목', `${launcherCommonSettings.length}개`],
['Endpoint', `${launcherCommonSettings.filter((item) => item.type === 'endpoint').length}개`],
['Database', `${launcherCommonSettings.filter((item) => item.type === 'database').length}개`],
['활성 상태', `${launcherCommonSettings.filter((item) => item.status === '활성').length}개`]
].map(([label, value]) => (
<Card key={label} className="p-5">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-3 text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
<div className="mt-4">
<Card className="min-w-0 overflow-hidden">
<CardHeader title="런처 연결 설정" description="여러 기능에서 함께 쓰는 주소, 인증, 저장소 관련 값을 정리합니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[1120px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="w-[280px] 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">Type</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">
{launcherCommonSettings.map((setting) => (
<tr key={setting.key} className="hover:bg-muted/20">
<td className="px-5 py-3">
<p className="inline-flex items-center gap-2 font-medium">
<span
className={cn(
'size-2 rounded-full',
setting.status === '활성' ? 'bg-emerald-400' : setting.status === '주의' ? 'bg-amber-400' : 'bg-muted-foreground'
)}
/>
{setting.name}
</p>
</td>
<td className="px-5 py-3 font-medium">{setting.key}</td>
<td className="px-5 py-3">{setting.type}</td>
<td className="px-5 py-3 text-muted-foreground">{setting.value}</td>
<td className="px-5 py-3 text-right">
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
{apiModalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-[1280px] overflow-hidden rounded-lg border border-border bg-card shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-base font-semibold">공통 인증 API</h2>
<p className="mt-1 text-xs text-muted-foreground">atnmy-stdo-auth에서 제공하는 런처 및 앱 연동 엔드포인트입니다.</p>
</div>
<Button variant="ghost" className="h-8 px-3 text-[13px]" onClick={() => setApiModalOpen(false)}>
닫기
</Button>
</div>
<div className="max-h-[70vh] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[980px] 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">Method</th>
<th className="px-5 py-3 font-medium">Path</th>
<th className="px-5 py-3 font-medium">용도</th>
<th className="px-5 py-3 font-medium">권한</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{authApiEndpoints.map((endpoint) => (
<tr key={`${endpoint.method}-${endpoint.path}`} className="hover:bg-muted/20">
<td className="px-5 py-3">{endpoint.group}</td>
<td className="px-5 py-3 font-semibold">{endpoint.method}</td>
<td className="px-5 py-3 font-medium">{endpoint.path}</td>
<td className="px-5 py-3 text-muted-foreground">{endpoint.purpose}</td>
<td className="px-5 py-3">
<Badge tone={endpoint.auth === '없음' ? 'neutral' : endpoint.auth === 'ADMIN' ? 'amber' : 'blue'}>
{endpoint.auth}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : null}
</>
);
}
function loadPlatformRepos() {
return apiFetch(`${apiBaseUrl}/gitea/repos?owner=platform&limit=8`, { headers: authHeaders() });
}
function loadLauncherVersions() {
return apiFetch(`${apiBaseUrl}/launcher/versions`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('Launcher versions request failed');
}
return response.json() as Promise<ApiLauncherVersion[]>;
});
}
function loadApps() {
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('Apps request failed');
}
return response.json() as Promise<ApiApp[]>;
})
.then(filterAppsByCurrentAdmin);
}
function loadAppReleases() {
return apiFetch(`${apiBaseUrl}/apps/releases`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('App releases request failed');
}
return response.json() as Promise<ApiAppRelease[]>;
});
}
function loadNotices() {
return apiFetch(`${apiBaseUrl}/notices`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('Notices request failed');
}
return response.json() as Promise<{ notices: ApiNotice[] }>;
})
.then((data) => data.notices ?? []);
}
function loadLicensePlans() {
return apiFetch(`${apiBaseUrl}/licenses/plans`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('License plans request failed');
}
return response.json() as Promise<{ plans?: LicensePlanSummary[] }>;
})
.then((data) => data.plans ?? []);
}
function loadCommonAppPipelines() {
return apiFetch(`${apiBaseUrl}/woodpecker/repos`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('Woodpecker repositories request failed');
}
return response.json() as Promise<{ repos: CommonAppPipeline[] }>;
})
.then((data) => data.repos ?? []);
}
function loadRuntimeNamespaceResources(namespace: string) {
return apiFetch(`${apiBaseUrl}/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('Runtime resources request failed');
}
return response.json() as Promise<RuntimeResourceResponse>;
});
}
function loadRuntimePodMetrics(namespace?: string) {
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
return apiFetch(`${apiBaseUrl}/runtime/metrics/pods${query}`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('Runtime pod metrics request failed');
}
return response.json() as Promise<RuntimePodMetric[]>;
});
}
function loadAppSources() {
return apiFetch(`${apiBaseUrl}/apps/sources`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('App sources request failed');
}
return response.json() as Promise<ApiAppComponent[]>;
});
}
function loadUserSessions() {
return apiFetch(`${apiBaseUrl}/users/sessions`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('User sessions request failed');
}
return response.json() as Promise<{ sessions: UserSessionRow[] }>;
})
.then((data) => data.sessions ?? []);
}
function loadAdmins() {
return apiFetch(`${apiBaseUrl}/admins`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('Admins request failed');
}
return response.json() as Promise<AdminRow[] | { admins: AdminRow[] }>;
})
.then((data) => (Array.isArray(data) ? data : data.admins ?? []));
}
function getBuildTargetForType(type: string) {
const targets: Record<string, string> = {
ELECTRON: 'AppImage',
CONTAINER: 'container-image',
STATIC_WEB: 'web-image',
DATABASE: 'database',
MIGRATION: 'prisma/migrations',
};
return targets[type] ?? 'container-image';
}
function formatDateTime(value: string | null) {
return formatDateTimeValue(value);
}
function getHttpCloneUrl(repository: string) {
return `${giteaBaseUrl.replace(/\/$/, '')}/${repository}.git`;
}
function getSshCloneUrl(repository: string) {
return `${giteaSshBaseUrl.replace(/\/$/, '')}/${repository}.git`;
}
function copyToClipboard(value: string) {
void navigator.clipboard?.writeText(value);
}
function getBuildArtifact(component: ApiAppComponent, branch: string, commitSha: string) {
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
if (component.type === 'ELECTRON') {
return `${component.name}-${suffix}.AppImage`;
}
if (component.type === 'DATABASE' || component.type === 'MIGRATION') {
return `${component.name}:${branch}:migration-check`;
}
return `registry.local/${component.name}:${suffix}`;
}
function getBuildLogLines(job: {
component: string;
repository: string;
branch: string;
commit: string;
target: string;
artifact: string;
status: string;
}) {
return [
`[00:00] build job initialized: ${job.component}`,
`[00:01] repository: ${job.repository}`,
`[00:02] checkout branch: ${job.branch}`,
`[00:03] checkout commit: ${job.commit}`,
`[00:08] install dependencies`,
`[00:25] run build target: ${job.target}`,
`[01:10] package artifact: ${job.artifact}`,
`[01:20] status: ${job.status}`
];
}
function AppBuildsPage() {
type BuildJobRow = {
id: string;
no: number;
app: string;
component: string;
repository: string;
branch: string;
commit: string;
target: string;
artifact: string;
requestedBy: string;
startedAt: string;
endedAt: string;
duration: string;
status: string;
};
const [apiApps, setApiApps] = useState<ApiApp[]>([]);
const [apiSources, setApiSources] = useState<ApiAppComponent[]>([]);
const [buildsLoading, setBuildsLoading] = useState(true);
const buildsLoadingVisible = useMinimumLoading(buildsLoading);
const [selectedAppId, setSelectedAppId] = useState('');
const [selectedComponentId, setSelectedComponentId] = useState('');
const [selectedBranch, setSelectedBranch] = useState('release');
const [commitSha, setCommitSha] = useState('');
const [buildRows, setBuildRows] = useState<BuildJobRow[]>([]);
const [logJob, setLogJob] = useState<BuildJobRow | null>(null);
useEffect(() => {
let mounted = true;
setBuildsLoading(true);
Promise.all([loadApps(), loadAppSources()])
.then(([apps, sources]) => {
if (!mounted) return;
setApiApps(apps);
setApiSources(sources);
setSelectedAppId((current) => current || apps[0]?.id || '');
const firstSource = sources.find((source) => source.appId === (apps[0]?.id ?? '')) ?? sources[0];
setSelectedComponentId((current) => current || firstSource?.id || '');
setSelectedBranch(firstSource?.branch || 'release');
setBuildsLoading(false);
})
.catch(() => {
if (!mounted) return;
setApiApps([]);
setApiSources([]);
setBuildsLoading(false);
});
return () => {
mounted = false;
};
}, []);
const selectedApp = apiApps.find((app) => app.id === selectedAppId);
const componentOptions = apiSources.filter((source) => source.appId === selectedAppId);
const selectedComponent = apiSources.find((source) => source.id === selectedComponentId) ?? componentOptions[0];
const selectedTarget = selectedComponent?.buildTarget || (selectedComponent ? getBuildTargetForType(selectedComponent.type) : '-');
const predictedArtifact = selectedComponent
? getBuildArtifact(selectedComponent, selectedBranch, commitSha)
: '-';
useEffect(() => {
if (!selectedAppId) return;
const firstComponent = apiSources.find((source) => source.appId === selectedAppId);
setSelectedComponentId(firstComponent?.id || '');
setSelectedBranch(firstComponent?.branch || 'release');
}, [apiSources, selectedAppId]);
const runBuild = () => {
if (!selectedApp || !selectedComponent) return;
const nextRow: BuildJobRow = {
id: `local-${Date.now()}`,
no: buildRows.length + 1,
app: selectedApp.name,
component: selectedComponent.name,
repository: selectedComponent.repository || '-',
branch: selectedBranch,
commit: commitSha || '-',
target: selectedTarget,
artifact: predictedArtifact,
requestedBy: 'admin',
startedAt: '방금',
endedAt: '-',
duration: '대기 중',
status: selectedComponent.repository ? '대기' : '검토필요'
};
setBuildRows((prev) => [nextRow, ...prev.map((row, index) => ({ ...row, no: prev.length - index }))]);
setLogJob(nextRow);
};
const retryBuild = (job: BuildJobRow) => {
setBuildRows((prev) => [
{
...job,
id: `retry-${Date.now()}`,
no: prev.length + 1,
startedAt: '방금',
endedAt: '-',
duration: '대기 중',
status: '대기'
},
...prev.map((row, index) => ({ ...row, no: prev.length - index }))
]);
};
const buildColumns: Array<DataTableColumn<BuildJobRow>> = [
{
key: 'no',
header: 'No.',
className: 'w-20 px-5 py-3 font-medium',
cell: (job) => <span className="font-semibold">{job.no}</span>
},
{
key: 'app',
header: '앱',
className: 'w-[180px] px-5 py-3 font-medium',
cell: (job) => <span className="font-semibold">{job.app}</span>
},
{
key: 'component',
header: '구성요소',
cell: (job) => <span>{job.component}</span>
},
{
key: 'repository',
header: 'Repository',
cell: (job) => <span className="text-muted-foreground">{job.repository}</span>
},
{
key: 'branch',
header: 'Branch',
cell: (job) => <span>{job.branch}</span>
},
{
key: 'commit',
header: 'Commit',
cell: (job) => <span className="font-mono text-xs text-muted-foreground">{job.commit}</span>
},
{
key: 'target',
header: '빌드 대상',
cell: (job) => <span className="text-muted-foreground">{job.target}</span>
},
{
key: 'artifact',
header: '결과물',
cell: (job) => <span className="text-muted-foreground">{job.artifact}</span>
},
{
key: 'requestedBy',
header: '요청자',
cell: (job) => <span className="text-muted-foreground">{job.requestedBy}</span>
},
{
key: 'startedAt',
header: '시작',
cell: (job) => <span className="text-muted-foreground">{job.startedAt}</span>
},
{
key: 'endedAt',
header: '종료',
cell: (job) => <span className="text-muted-foreground">{job.endedAt}</span>
},
{
key: 'status',
header: '상태',
cell: (job) => <Badge tone={getTone(job.status)}>{job.status}</Badge>
},
{
key: 'actions',
header: '',
className: 'w-28 px-5 py-3 text-right font-medium',
cell: (job) => (
<div className="flex justify-end gap-2">
<Button variant="outline" className="size-8 px-0" title="로그 보기" aria-label="로그 보기" onClick={() => setLogJob(job)}>
<MoreHorizontal className="size-4" />
</Button>
<Button variant="outline" className="size-8 px-0" title="재실행" aria-label="재실행" onClick={() => retryBuild(job)}>
<RotateCcw className="size-4" />
</Button>
</div>
)
}
];
const buildFilters: Array<DataTableFilter<BuildJobRow>> = [
{
key: 'app',
label: '앱',
options: [...new Set(buildRows.map((job) => job.app))].map((app) => ({ label: app, value: app })),
getValue: (job) => job.app
},
{
key: 'status',
label: '상태',
options: [...new Set(buildRows.map((job) => job.status))].map((status) => ({ label: status, value: status })),
getValue: (job) => job.status
}
];
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="빌드 관리"
description="Gitea 소스에서 컨테이너 이미지와 런처 다운로드용 클라이언트 산출물을 만드는 작업을 관리합니다."
actions={
<Button className="h-8 px-3 text-[13px]" onClick={runBuild} disabled={!selectedComponent || buildsLoading}>
<Play className="size-4" />빌드 실행
</Button>
}
/>
<Card className="mb-4">
<CardHeader title="빌드 요청" description="앱 그룹, 구성요소, 브랜치, 커밋 기준으로 빌드 작업을 생성합니다." />
<div className="grid gap-4 p-5 xl:grid-cols-[1.2fr_1fr]">
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">앱 그룹</span>
<select
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"
value={selectedAppId}
onChange={(event) => setSelectedAppId(event.target.value)}
>
{apiApps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
{apiApps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
<select
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"
value={selectedComponent?.id ?? ''}
onChange={(event) => {
const next = apiSources.find((source) => source.id === event.target.value);
setSelectedComponentId(event.target.value);
setSelectedBranch(next?.branch || 'release');
}}
>
{componentOptions.length === 0 ? <option value="">구성요소 없음</option> : null}
{componentOptions.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Branch</span>
<select
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"
value={selectedBranch}
onChange={(event) => setSelectedBranch(event.target.value)}
>
<option value="develop">develop</option>
<option value="release">release</option>
<option value="main">main</option>
</select>
</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 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={commitSha}
onChange={(event) => setCommitSha(event.target.value)}
placeholder="비워두면 브랜치 최신 커밋 기준"
/>
</label>
</div>
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-4 text-sm">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Repository</span>
<span className="truncate font-semibold">{selectedComponent?.repository || '미연결'}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">빌드 대상</span>
<span className="truncate font-semibold">{selectedTarget}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">예상 결과물</span>
<span className="truncate font-semibold">{predictedArtifact}</span>
</div>
<Button className="mt-1 h-8 px-3 text-[13px]" onClick={runBuild} disabled={!selectedComponent || buildsLoading}>
<Play className="size-4" />빌드 실행
</Button>
</div>
</div>
</Card>
<DataTableCard
title="빌드 작업 목록"
rows={buildRows}
columns={buildColumns}
filters={buildFilters}
searchPlaceholder="앱, 구성요소, 저장소, 결과물 검색"
getSearchText={(job) => `${job.app} ${job.component} ${job.repository} ${job.branch} ${job.commit} ${job.target} ${job.artifact} ${job.status}`}
getRowKey={(job) => job.id}
pageSize={3}
minWidth={1480}
/>
{logJob ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-xl">
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold">빌드 로그</h2>
<p className="mt-1 text-xs text-muted-foreground">{logJob.app} · {logJob.component} · {logJob.branch}</p>
</div>
<Button variant="ghost" onClick={() => setLogJob(null)}>닫기</Button>
</div>
<div className="max-h-[70vh] overflow-auto bg-background p-5 font-mono text-xs leading-6 text-muted-foreground">
{getBuildLogLines(logJob).map((line) => (
<p key={line}>{line}</p>
))}
</div>
</div>
</div>
) : null}
<LoadingModal open={buildsLoadingVisible} />
</>
);
}
function AppReleasesPage() {
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="릴리즈 관리"
description="브랜치, 커밋, 빌드 산출물을 묶어 환경별로 배포 가능한 릴리즈를 생성합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />릴리즈 생성</Button>}
/>
<div className="grid gap-4 xl:grid-cols-3">
{appReleasePipelines.map((release) => (
<Card key={`${release.app}-${release.version}`} className="p-5">
<div className="flex items-center justify-between gap-4">
<p className="truncate text-sm font-semibold">{release.app}</p>
<Badge tone={getTone(release.status)}>{release.status}</Badge>
</div>
<p className="mt-3 text-2xl font-semibold">{release.version}</p>
<p className="mt-2 text-xs text-muted-foreground">{`${release.sourceBranch} -> ${release.releaseBranch} · ${release.commit}`}</p>
<p className="mt-3 text-sm text-muted-foreground">{release.artifacts}</p>
<p className="mt-3 text-xs text-muted-foreground">{release.createdAt}</p>
</Card>
))}
</div>
</>
);
}
function AppDeploymentsPage() {
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="배포 관리"
description="생성된 릴리즈를 개발, 검수, 운영 환경에 배포하고 재배포와 롤백을 관리합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus 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-[980px] 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">환경</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">시각</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{appDeploymentJobs.map((job) => (
<tr key={`${job.app}-${job.environment}-${job.time}`} className="hover:bg-muted/20">
<td className="px-5 py-3 font-semibold">{job.app}</td>
<td className="px-5 py-3">{job.environment}</td>
<td className="px-5 py-3">{job.release}</td>
<td className="px-5 py-3 text-muted-foreground">{job.strategy}</td>
<td className="px-5 py-3 text-muted-foreground">{job.target}</td>
<td className="px-5 py-3">{job.requestedBy}</td>
<td className="px-5 py-3 text-muted-foreground">{job.time}</td>
<td className="px-5 py-3"><Badge tone={getTone(job.status)}>{job.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function AppMigrationsPage() {
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="마이그레이션"
description="앱 릴리즈에 포함된 DB 마이그레이션 경로와 실행 이력을 관리합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />마이그레이션 실행</Button>}
/>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="마이그레이션 작업" description="브랜치별 DB 변경 적용 상태입니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] 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">구성요소</th>
<th className="px-5 py-3 font-medium">Branch</th>
<th className="px-5 py-3 font-medium">Path</th>
<th className="px-5 py-3 font-medium">Version</th>
<th className="px-5 py-3 font-medium">시각</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{appMigrationJobs.map((job) => (
<tr key={`${job.app}-${job.version}`} className="hover:bg-muted/20">
<td className="px-5 py-3 font-semibold">{job.app}</td>
<td className="px-5 py-3">{job.component}</td>
<td className="px-5 py-3">{job.branch}</td>
<td className="px-5 py-3 text-muted-foreground">{job.path}</td>
<td className="px-5 py-3 text-muted-foreground">{job.version}</td>
<td className="px-5 py-3 text-muted-foreground">{job.time}</td>
<td className="px-5 py-3"><Badge tone={getTone(job.status)}>{job.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function RuntimeConfigPage() {
return (
<>
<PageTitle eyebrow="어플리케이션" title="실행 설정" description="클러스터에서 실행 중인 앱 워크로드와 노드 배치, 재시작 상태를 확인합니다." />
<Card className="min-w-0 overflow-hidden">
<CardHeader title="워크로드 현황" description="앱 그룹별 실행 리소스와 Pod 상태입니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[1180px] 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">네임스페이스</th>
<th className="px-5 py-3 font-medium">워크로드</th>
<th className="px-5 py-3 font-medium">Pod</th>
<th className="px-5 py-3 font-medium">Image</th>
<th className="px-5 py-3 font-medium">Replicas</th>
<th className="px-5 py-3 font-medium">Node</th>
<th className="px-5 py-3 font-medium">재시작</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{k3sWorkloads.map((item) => (
<tr key={`${item.namespace}-${item.pod}`} className="hover:bg-muted/20">
<td className="px-5 py-3 font-medium">{item.app}</td>
<td className="px-5 py-3 text-muted-foreground">{item.namespace}</td>
<td className="px-5 py-3">{item.workload}</td>
<td className="px-5 py-3 text-muted-foreground">{item.pod}</td>
<td className="px-5 py-3 text-muted-foreground">{item.image}</td>
<td className="px-5 py-3">{item.replicas}</td>
<td className="px-5 py-3 text-muted-foreground">{item.node}</td>
<td className="px-5 py-3">{item.restarts}</td>
<td className="px-5 py-3"><Badge tone={getTone(item.status)}>{item.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function RuntimeLogsPage() {
return (
<>
<PageTitle eyebrow="어플리케이션" title="로그/이벤트" description="실행 중인 워크로드의 로그, 이벤트, 최근 제어 명령 결과를 확인합니다." />
<Card>
<CardHeader title="최근 작업" description="로그 조회, 이벤트 조회, 재시작 같은 런타임 제어 요청입니다." />
<div className="divide-y divide-border">
{runtimeCommands.map((item) => (
<div key={`${item.target}-${item.command}`} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{item.command}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.target} · {item.requestedBy}</p>
</div>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
))}
</div>
</Card>
</>
);
}
function ContainerConfigPage() {
const resources = containerBlueprints.flatMap((blueprint) => blueprint.resources.map((resource) => ({ app: blueprint.app, ...resource })));
return (
<>
<PageTitle eyebrow="플랫폼 런타임" title="ConfigMap/Secret" description="워크로드별 환경변수와 시크릿 연결 수를 관리합니다." />
<div className="grid gap-4 xl:grid-cols-3">
{resources.map((resource) => (
<Card key={`${resource.app}-${resource.name}`} className="p-5">
<p className="text-sm font-semibold">{resource.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{resource.app}</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">Env</p>
<p className="mt-2 text-xl font-semibold">{resource.env}</p>
</div>
<div className="rounded-md bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">Secrets</p>
<p className="mt-2 text-xl font-semibold">{resource.secrets}</p>
</div>
</div>
</Card>
))}
</div>
</>
);
}
function ContainerStoragePage() {
const resources = containerBlueprints.flatMap((blueprint) => blueprint.resources.map((resource) => ({ app: blueprint.app, namespace: blueprint.namespace, ...resource })));
return (
<>
<PageTitle eyebrow="플랫폼 런타임" title="스토리지" description="StatefulSet, PVC, 볼륨 마운트가 필요한 리소스를 관리합니다." />
<Card>
<CardHeader title="스토리지 리소스" description="PVC가 필요한 리소스만 표시합니다." />
<div className="divide-y divide-border">
{resources
.filter((resource) => resource.storage !== '-')
.map((resource) => (
<div key={`${resource.app}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{resource.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{resource.app} · {resource.namespace}</p>
</div>
<Badge tone="blue">{resource.storage}</Badge>
</div>
))}
</div>
</Card>
</>
);
}
function TailscaleStatusPage() {
const activeNodes = tailscaleNodes.filter((node) => node.status === '활성').length;
const routeNodes = tailscaleNodes.filter((node) => node.routes !== '-').length;
return (
<>
<PageTitle
eyebrow="테일스케일"
title="노드 현황"
description="플랫폼 게이트웨이가 수집한 tailnet 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다."
actions={
<Button className="h-8 px-3 text-[13px]">
<Activity className="size-4" />
상태 갱신
</Button>
}
/>
<div className="grid gap-4 md:grid-cols-3">
{[
['등록 노드', `${tailscaleNodes.length}대`, '게이트웨이 수집 기준'],
['온라인', `${activeNodes}대`, '최근 접속 정상'],
['Subnet Router', `${routeNodes}대`, '클러스터 CIDR 광고']
].map(([label, value, hint]) => (
<Card key={label} className="p-5">
<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">{hint}</p>
</Card>
))}
</div>
<div className="mt-4">
<Card className="min-w-0 overflow-hidden">
<CardHeader title="노드 현황" description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다." />
<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">
{tailscaleNodes.map((node) => (
<tr key={node.name} className="hover:bg-muted/20">
<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">
<Badge tone={getTone(node.status)}>{node.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
</>
);
}
function TailscaleRoutesPage() {
const routeNodes = tailscaleNodes.filter((node) => node.routes !== '-');
return (
<>
<PageTitle eyebrow="테일스케일" title="라우트" description="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." />
<Card>
<CardHeader title="라우트 광고" description="승인된 subnet route와 노드 상태입니다." />
<div className="divide-y divide-border">
{routeNodes.map((node) => (
<div key={node.name} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{node.routes}</p>
<p className="mt-1 text-xs text-muted-foreground">{node.name} · {node.ip}</p>
</div>
<Badge tone={getTone(node.status)}>{node.status}</Badge>
</div>
))}
</div>
</Card>
</>
);
}
function TailscaleAclPage() {
return (
<>
<PageTitle eyebrow="테일스케일" title="ACL" description="사용자 그룹과 장비 태그 기준의 tailnet 접근 정책을 관리합니다." />
<div className="grid gap-4 xl:grid-cols-3">
{[
['강사 장비 접근', 'group:instructors -> tag:teaching-device', '활성'],
['학생 워크스페이스', 'group:students -> tag:assigned-workspace', '활성'],
['운영자 전체 접근', 'group:ops -> tag:platform-admin', '활성']
].map(([name, rule, status]) => (
<Card key={name} className="p-5">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-semibold">{name}</p>
<Badge tone={getTone(status)}>{status}</Badge>
</div>
<p className="mt-3 text-xs text-muted-foreground">{rule}</p>
</Card>
))}
</div>
</>
);
}
function TailscaleDiagnosticsPage() {
return (
<>
<PageTitle
eyebrow="테일스케일"
title="연결 진단"
description="Tailnet, subnet route, 플랫폼 게이트웨이 연동 상태를 진단합니다."
actions={
<Button className="h-8 px-3 text-[13px]">
<Activity 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 key={setting.key} className="hover:bg-muted/20">
<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">
<Badge tone={getTone(setting.status)}>{setting.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function DashboardPage({
onLauncherVersionsOpen,
onAppManagementOpen,
onRuntimeLogsOpen,
onNoticesOpen,
onUserSessionsOpen
}: {
onLauncherVersionsOpen: () => void;
onAppManagementOpen: () => void;
onRuntimeLogsOpen: () => void;
onNoticesOpen: () => void;
onUserSessionsOpen: () => void;
}) {
const [launcherRows, setLauncherRows] = useState<ApiLauncherVersion[]>([]);
const [dashboardApps, setDashboardApps] = useState<ApiApp[]>([]);
const [dashboardReleases, setDashboardReleases] = useState<ApiAppRelease[]>([]);
const [recentSessions, setRecentSessions] = useState<UserSessionRow[]>([]);
const [licensePlansSummary, setLicensePlansSummary] = useState<LicensePlanSummary[]>([]);
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
const [dashboardLoading, setDashboardLoading] = useState(true);
useEffect(() => {
let mounted = true;
async function loadDashboard() {
setDashboardLoading(true);
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, licensePlansResult, commonAppsResult] = await Promise.allSettled([
loadLauncherVersions(),
loadApps(),
loadAppReleases(),
loadUserSessions(),
loadNotices(),
loadLicensePlans(),
loadCommonAppPipelines()
]);
const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : [];
const nextApps = appsResult.status === 'fulfilled' ? appsResult.value : [];
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 namespaceApps = nextApps.filter((app) => app.namespace);
const [runtimeResults, metricResults] = await Promise.all([
Promise.allSettled(
namespaceApps.map((app) => loadRuntimeNamespaceResources(app.namespace as string).then((result) => ({ app, result })))
),
Promise.allSettled(
namespaceApps.map((app) => loadRuntimePodMetrics(app.namespace as string).then((metrics) => ({ namespace: app.namespace as string, metrics })))
)
]);
const metricByPod = new Map<string, RuntimePodMetric>();
metricResults.forEach((result) => {
if (result.status !== 'fulfilled') return;
result.value.metrics.forEach((metric) => {
metricByPod.set(`${metric.namespace}/${metric.pod}`, metric);
});
});
const nextRuntimeResources = runtimeResults.flatMap((result) => {
if (result.status !== 'fulfilled' || !result.value.result.exists) return [];
return result.value.result.resources.map((resource) => {
const namespace = result.value.result.namespace;
const metric = resource.kind === 'Pod' ? metricByPod.get(`${namespace}/${resource.name}`) : undefined;
return {
...resource,
namespace,
appName: result.value.app.name,
cpu: metric?.cpu,
memory: metric?.memory
};
});
});
if (!mounted) return;
setLauncherRows(nextVersions);
setDashboardApps(nextApps);
setDashboardReleases(nextReleases);
setRecentSessions(nextSessions);
setLicensePlansSummary(nextLicensePlans);
setCommonAppPipelines(nextCommonApps);
setDashboardNotices(nextNotices);
setRuntimeResources(nextRuntimeResources);
setDashboardLoading(false);
}
loadDashboard().catch(() => {
if (!mounted) return;
setDashboardLoading(false);
});
return () => {
mounted = false;
};
}, []);
return (
<>
<PageTitle
eyebrow="플랫폼 운영"
title="대시보드"
description="플랫폼의 현재 상태와 운영 흐름을 빠르게 파악합니다."
/>
<MetricCards apps={dashboardApps} sessions={recentSessions} licensePlans={licensePlansSummary} runtimeResources={runtimeResources} loading={dashboardLoading} />
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} />
</div>
<div className="mt-4">
<AppRegistryTable apps={dashboardApps} releases={dashboardReleases} loading={dashboardLoading} onOpen={onAppManagementOpen} />
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<ContainerPanel resources={runtimeResources} loading={dashboardLoading} onOpen={onRuntimeLogsOpen} />
<NoticePanel notices={dashboardNotices} loading={dashboardLoading} onOpen={onNoticesOpen} />
</div>
</>
);
}
function App() {
const location = useLocation();
const navigate = useNavigate();
const activeView = viewFromPath(location.pathname);
const [menuSearch, setMenuSearch] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(() => Boolean(localStorage.getItem('studio-admin-token')));
const [currentAdmin, setCurrentAdmin] = useState<CurrentAdmin | null>(() => readCurrentAdmin());
const currentRole = currentAdmin?.role ?? '';
const isOwner = isOwnerRole(currentRole);
const isViewer = isViewerRole(currentRole);
const changeView = (view: ViewKey) => {
if (view === 'adminManagement' && !isOwner) {
navigate('/dashboard');
return;
}
navigate(pathByView[view] ?? '/dashboard');
};
const logout = () => {
clearAdminSession();
setCurrentAdmin(null);
setIsAuthenticated(false);
navigate('/dashboard', { replace: true });
};
useEffect(() => {
const handleAuthExpired = () => {
setCurrentAdmin(null);
setIsAuthenticated(false);
navigate('/dashboard', { replace: true });
};
window.addEventListener(authExpiredEventName, handleAuthExpired);
return () => window.removeEventListener(authExpiredEventName, handleAuthExpired);
}, [navigate]);
useEffect(() => {
if (location.pathname === '/') navigate('/dashboard', { replace: true });
}, [location.pathname, navigate]);
useEffect(() => {
if (activeView === 'adminManagement' && !isOwner) {
navigate('/dashboard', { replace: true });
}
}, [activeView, isOwner, navigate]);
const renderView = () => {
switch (activeView) {
case 'launcherVersions':
return <LauncherVersionsApiPage />;
case 'launcherComponents':
return <LauncherComponentsPage />;
case 'launcherSources':
return <LauncherSourcePage />;
case 'launcherBuilds':
return <LauncherBuildPage />;
case 'launcherApps':
return <LauncherAppsApiPage />;
case 'launcherCommonSettings':
return <LauncherCommonSettingsApiPage />;
case 'notices':
return <NoticesApiPage />;
case 'adminManagement':
if (!isOwner) return null;
return <AdminManagementApiPage />;
case 'userSessions':
return <UserSessionsApiPage />;
case 'licenses':
return <LicensesApiPage />;
case 'appManagement':
return <AppManagementApiPage />;
case 'appSources':
return <AppSourcesApiPage />;
case 'appBuilds':
return <BuildManagementPage />;
case 'appReleases':
return <ReleaseManagementPage />;
case 'appDeployments':
return <DeploymentManagementPage />;
case 'appMigrations':
return <MigrationManagementPage />;
case 'gatewayStatus':
return <GatewayStatusRestoredPage />;
case 'gatewayAgents':
return <GatewayAgentsRestoredPage />;
case 'gatewayDevices':
return <GatewayDevicesRestoredPage />;
case 'tailscaleStatus':
return <TailscaleStatusRestoredPage />;
case 'tailscaleRoutes':
return <TailscaleRoutesRestoredPage />;
case 'tailscaleAcl':
return <TailscaleAclRestoredPage />;
case 'tailscaleDiagnostics':
return <TailscaleDiagnosticsRestoredPage />;
case 'agentRegistration':
return <AgentRegistrationRestoredPage />;
case 'agentInstallStatus':
return <AgentInstallStatusRestoredPage />;
case 'agentSshKeys':
return <AgentSshKeysRestoredPage />;
case 'agentWorkspace':
return <AgentWorkspaceRestoredPage />;
case 'agentCommands':
return <AgentCommandsRestoredPage />;
case 'runtimeConfig':
return <RuntimeConfigApiPage />;
case 'runtimeLogs':
return <RuntimeLogsApiPage />;
case 'runtimeDeviceControl':
return <RuntimeDeviceControlPage />;
default:
return (
<DashboardPage
onLauncherVersionsOpen={() => changeView('launcherVersions')}
onAppManagementOpen={() => changeView('appManagement')}
onRuntimeLogsOpen={() => changeView('runtimeLogs')}
onNoticesOpen={() => changeView('notices')}
onUserSessionsOpen={() => changeView('userSessions')}
/>
);
}
};
if (!isAuthenticated) {
return <LoginPage onLogin={(admin) => {
setCurrentAdmin(admin);
setIsAuthenticated(true);
}} />;
}
return (
<div className="min-h-screen overflow-hidden bg-background text-foreground">
<div className="min-h-screen lg:pl-[17rem]">
<Sidebar activeView={activeView} menuSearch={menuSearch} currentAdmin={currentAdmin} onViewChange={changeView} />
<div className="flex h-screen min-w-0 flex-col">
<Topbar
menuSearch={menuSearch}
onMenuSearchChange={setMenuSearch}
currentAdmin={currentAdmin}
onLogout={logout}
/>
<main className={`scrollbar-stable min-h-0 flex-1 overflow-y-auto ${isViewer ? 'viewer-readonly' : ''}`}>
<div className="mx-auto w-full max-w-[1520px] px-6 pb-16 pt-6 md:px-10 md:pb-20 xl:px-14">
{renderView()}
</div>
</main>
</div>
</div>
</div>
);
}
export default App;