Files
studio-admin-fe/src/App.tsx
T
chyoo fd590540bc
ci/woodpecker/push/woodpecker Pipeline was successful
admin appPermissions
2026-07-09 09:06:55 +09:00

1659 lines
63 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Activity,
ChevronDown,
ChevronRight,
CircleHelp,
ExternalLink,
LogOut,
Search,
SlidersHorizontal
} from 'lucide-react';
import { navGroups } from './data/mock';
import { Badge, Button, Card, CardHeader, Progress } from './components/ui';
import { formatDateTime as formatDateTimeValue } from './lib/date-format';
import { apiBaseUrl, apiRequest, authExpiredEventName, clearAdminSession } from './lib/api';
import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
import { cn } from './lib/utils';
import { openPageAgent } from './lib/page-agent';
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 { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage';
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 { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
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 { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage';
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
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,
TailscaleAclPage as TailscaleAclRestoredPage,
TailscaleRoutesPage as TailscaleRoutesRestoredPage,
TailscaleStatusPage as TailscaleStatusRestoredPage
} from './pages/RestoredPages';
type ViewKey =
| 'dashboard'
| 'commonSources'
| 'commonBuilds'
| 'commonComponents'
| 'launcherSources'
| 'launcherBuilds'
| 'launcherVersions'
| 'launcherComponents'
| 'launcherApps'
| 'notices'
| 'appManagement'
| 'appRepositories'
| 'appSources'
| 'appBuilds'
| 'appReleases'
| 'appDeployments'
| 'appMigrations'
| 'gatewayStatus'
| 'gatewayAgents'
| 'gatewayDevices'
| 'tailscaleStatus'
| 'tailscaleRoutes'
| 'tailscaleAcl'
| 'agentRegistration'
| 'agentInstallStatus'
| 'agentSshKeys'
| 'agentWorkspace'
| 'agentCommands'
| 'runtimeConfig'
| 'runtimeLogs'
| 'runtimeDeviceControl'
| 'adminManagement'
| 'organizations'
| 'userSessions'
| 'licenses';
const viewByLabel: Record<string, ViewKey> = {
대시보드: 'dashboard',
'버전 관리': 'launcherVersions',
구성요소: 'launcherComponents',
'앱 연결': 'launcherApps',
공지사항: 'notices',
'앱 관리': 'appManagement',
'저장소 관리': 'appRepositories',
'소스 관리': 'appSources',
'빌드 관리': 'appBuilds',
'릴리즈 관리': 'appReleases',
'실행 설정': 'runtimeConfig',
'배포 관리': 'appDeployments',
마이그레이션: 'appMigrations',
'연결 상태': 'gatewayStatus',
'Agent 관리': 'gatewayAgents',
'장비 동기화': 'gatewayDevices',
'노드 현황': 'tailscaleStatus',
라우트: 'tailscaleRoutes',
ACL: 'tailscaleAcl',
'장비 등록': 'agentRegistration',
'설치 상태': 'agentInstallStatus',
'SSH 키 배포': 'agentSshKeys',
'Workspace 설정': 'agentWorkspace',
'원격 명령': 'agentCommands',
'로그/이벤트': 'runtimeLogs',
'장비 제어': 'runtimeDeviceControl',
'관리자 관리': 'adminManagement',
'조직 관리': 'organizations',
'사용자/세션': 'userSessions',
라이센스: 'licenses'
};
const pathByView: Record<ViewKey, string> = {
dashboard: '/dashboard',
commonSources: '/common-apps/sources',
commonBuilds: '/common-apps/builds',
commonComponents: '/common-apps/components',
launcherSources: '/launcher/sources',
launcherBuilds: '/launcher/builds',
launcherVersions: '/launcher/versions',
launcherComponents: '/launcher/components',
launcherApps: '/launcher/apps',
notices: '/operations/notices',
appManagement: '/applications/apps',
appRepositories: '/applications/repositories',
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',
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',
organizations: '/operations/organizations',
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';
}
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 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 DashboardJob = {
id: string;
status: string;
};
type ActiveWorkSummary = {
builds: number;
releases: number;
deployments: number;
};
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;
};
function formatDateTime(value?: string | null) {
return formatDateTimeValue(value);
}
function loadLauncherVersions() {
return apiRequest<ApiLauncherVersion[]>('/launcher/versions');
}
function loadApps() {
return apiRequest<ApiApp[]>('/apps')
.then(filterAppsByCurrentAdmin);
}
function loadAppReleases() {
return apiRequest<ApiAppRelease[]>('/apps/releases');
}
function loadUserSessions() {
return apiRequest<{ sessions?: UserSessionRow[] }>('/users/sessions')
.then((response) => (response.sessions ?? []).filter((session) => session.sessionId));
}
function loadNotices() {
return apiRequest<{ notices?: ApiNotice[] }>('/notices')
.then((response) => response.notices ?? []);
}
function loadCommonAppPipelines() {
return apiRequest<{ repos: CommonAppPipeline[] }>('/woodpecker/repos')
.then((result) => result.repos ?? []);
}
function isActiveWorkStatus(status?: string | null) {
return status === 'PENDING' || status === 'RUNNING';
}
function loadActiveWorkSummary(releases: ApiAppRelease[]): Promise<ActiveWorkSummary> {
const activeReleases = releases.filter((release) => isActiveWorkStatus(release.status)).length;
return Promise.allSettled([
apiRequest<{ jobs?: DashboardJob[] }>('/apps/builds?page=1&limit=20'),
apiRequest<{ deployments?: DashboardJob[] }>('/apps/deployments?page=1&limit=20')
]).then(([buildsResult, deploymentsResult]) => ({
builds: buildsResult.status === 'fulfilled'
? (buildsResult.value.jobs ?? []).filter((job) => isActiveWorkStatus(job.status)).length
: 0,
releases: activeReleases,
deployments: deploymentsResult.status === 'fulfilled'
? (deploymentsResult.value.deployments ?? []).filter((job) => isActiveWorkStatus(job.status)).length
: 0
}));
}
function loadRuntimeNamespaceResources(namespace: string) {
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
}
function loadRuntimePodMetrics(namespace: string) {
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`);
}
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 pageAgentAccessAttrs(allowed: boolean) {
return allowed
? {}
: {
'data-page-agent-ignore': 'true',
'data-page-agent-not-interactive': 'true'
};
}
function isTrustedClick(event: ReactMouseEvent<HTMLElement>) {
return event.nativeEvent.isTrusted;
}
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 = useMemo(
() => filterNavGroupsByRole(navGroups, currentAdmin?.role) as typeof navGroups,
[currentAdmin?.role]
);
useEffect(() => {
for (const group of roleNavGroups) {
for (const item of group.items) {
if (!('children' in item) || !item.children) continue;
const hasActiveChild = item.children.some((child) => resolveNavView(child) === activeView);
if (!hasActiveChild) continue;
setOpenMenus((prev) => (prev[item.label] ? prev : { ...prev, [item.label]: true }));
return;
}
}
}, [activeView, roleNavGroups]);
const visibleGroups = normalizedMenuSearch
? roleNavGroups
.map((group) => {
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) => {
const isPageAgentAllowedGroup = group.label === '플랫폼';
return (
<div key={group.label} {...pageAgentAccessAttrs(isPageAgentAllowedGroup)}>
<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"
{...pageAgentAccessAttrs(isPageAgentAllowedGroup)}
onClick={(event) => {
if (!isPageAgentAllowedGroup && !isTrustedClick(event)) return;
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}
{...pageAgentAccessAttrs(isPageAgentAllowedGroup)}
onClick={(event) => {
if (!isPageAgentAllowedGroup && !isTrustedClick(event)) return;
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 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" title="AI 도움말" aria-label="AI 도움말" onClick={openPageAgent}>
<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,
activeWork,
runtimeResources,
loading
}: {
apps: ApiApp[];
sessions: UserSessionRow[];
activeWork: ActiveWorkSummary;
runtimeResources: DashboardRuntimeResource[];
loading: boolean;
}) {
const activeApps = dashboardApps.filter((app) => isActiveStatus(app.status)).length;
const activeSessions = sessions.filter((session) => session.state === '활성').length;
const activeWorkTotal = activeWork.builds + activeWork.releases + activeWork.deployments;
const healthyRuntimeCount = runtimeResources.filter(isRuntimeResourceHealthy).length;
const runtimeRate = runtimeResources.length ? Math.round((healthyRuntimeCount / runtimeResources.length) * 100) : null;
const cards = [
{
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 ? '-' : `${activeWorkTotal}건`,
delta: `빌드 ${activeWork.builds} · 릴리즈 ${activeWork.releases} · 배포 ${activeWork.deployments}`,
hint: '대기/실행 중 작업 기준',
tone: activeWorkTotal > 0 ? '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">
<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>
</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"
{...pageAgentAccessAttrs(false)}
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(event) => {
if (!isTrustedClick(event)) return;
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, pageAgentAllowed = true }: { onClick: () => void; label: string; pageAgentAllowed?: boolean }) {
return (
<button
type="button"
{...pageAgentAccessAttrs(pageAgentAllowed)}
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(event) => {
if (!pageAgentAllowed && !isTrustedClick(event)) return;
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="공지사항으로 이동" pageAgentAllowed={false} />}
/>
<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 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 [activeWorkSummary, setActiveWorkSummary] = useState<ActiveWorkSummary>({ builds: 0, releases: 0, deployments: 0 });
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
const [dashboardLoading, setDashboardLoading] = useState(true);
const loadingVisible = useMinimumLoading(dashboardLoading);
useEffect(() => {
let mounted = true;
async function loadDashboard() {
setDashboardLoading(true);
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([
loadLauncherVersions(),
loadApps(),
loadAppReleases(),
loadUserSessions(),
loadNotices(),
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 nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
const nextActiveWork = await loadActiveWorkSummary(nextReleases);
const namespaceApps = nextApps.filter((app) => app.namespace);
const [runtimeResults, metricResults] = await Promise.all([
Promise.allSettled(
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);
setActiveWorkSummary(nextActiveWork);
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} activeWork={activeWorkSummary} 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>
<LoadingModal open={loadingVisible} />
</>
);
}
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(() => {
const handleAdminPermissionsChange = () => {
setCurrentAdmin(readCurrentAdmin());
};
window.addEventListener('studio-admin-app-permissions-change', handleAdminPermissionsChange);
return () => window.removeEventListener('studio-admin-app-permissions-change', handleAdminPermissionsChange);
}, []);
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 'commonSources':
return <CommonAppSourcePage />;
case 'commonBuilds':
return <CommonAppBuildPage />;
case 'commonComponents':
return <CommonAppComponentsPage />;
case 'launcherVersions':
return <LauncherVersionsApiPage />;
case 'launcherComponents':
return <LauncherComponentsPage />;
case 'launcherSources':
return <LauncherSourcePage />;
case 'launcherBuilds':
return <LauncherBuildPage />;
case 'launcherApps':
return <LauncherAppsApiPage />;
case 'notices':
return <NoticesApiPage />;
case 'adminManagement':
if (!isOwner) return null;
return <AdminManagementApiPage />;
case 'organizations':
return <OrganizationsApiPage />;
case 'userSessions':
return <UserSessionsApiPage />;
case 'licenses':
return <LicensesApiPage />;
case 'appManagement':
return <AppManagementApiPage />;
case 'appRepositories':
return <RepositoryManagementPage />;
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 '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;