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 { LauncherLogsPage } from './pages/launcher/LauncherLogsPage'; import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage'; import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage'; import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage'; import { CommonAppLogsPage } from './pages/common-apps/CommonAppLogsPage'; 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' | 'commonLogs' | 'launcherSources' | 'launcherBuilds' | 'launcherVersions' | 'launcherComponents' | 'launcherLogs' | '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 = { 대시보드: '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 = { dashboard: '/dashboard', commonSources: '/common-apps/sources', commonBuilds: '/common-apps/builds', commonComponents: '/common-apps/components', commonLogs: '/common-apps/logs', launcherSources: '/launcher/sources', launcherBuilds: '/launcher/builds', launcherVersions: '/launcher/versions', launcherComponents: '/launcher/components', launcherLogs: '/launcher/logs', 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; 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; commitSha?: string | null; durationSeconds?: number | null; 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('/launcher/versions'); } function loadApps() { return apiRequest('/apps') .then(filterAppsByCurrentAdmin); } function loadAppReleases() { return apiRequest('/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 { 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(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`); } function loadRuntimePodMetrics(namespace: string) { return apiRequest(`/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) { 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>({}); 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 ( ); } function PageTitle({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description: string; actions?: ReactNode; }) { return (

{eyebrow}

{title}

{description}

{actions ?
{actions}
: null}
); } 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(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 (
onMenuSearchChange(event.target.value)} />
{profileOpen ? (

{currentAdmin?.name || accountLabel}

{accountLabel}

) : null}
); } 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) => { 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 (
Autonomy Studio

Studio Management System

관리자 로그인

계정 정보를 입력해 주세요.

{loginError ? (

이메일 또는 비밀번호를 확인해 주세요.

) : null}
); } function isActiveStatus(status?: string | null) { return ['활성', 'ACTIVE', 'READY', 'RUNNING', 'SUCCEEDED'].includes(status ?? ''); } function getLatestReleaseByApp(releases: ApiAppRelease[]) { return releases.reduce>((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 = { PENDING: '대기', RUNNING: '실행 중', SUCCEEDED: '성공', FAILED: '실패', CANCELED: '취소' }; return status ? labels[status] ?? status : '-'; } function SkeletonBlock({ className }: { className?: string }) { return (
); } 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 (
{cards.map((metric) => ( {loading ? (

{metric.label}

{metric.hint}

) : ( <>

{metric.label}

{metric.delta}
{metric.value}

{metric.hint}

)}
))}
); } function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVersion[]; loading: boolean; onOpen: () => void }) { const activeVersion = versions.find((version) => version.isActive) ?? versions[0]; return ( } />
{loading ? (

Studio Launcher

배포 파일

다운로드 URL

릴리즈 노트

) : activeVersion ? (

Studio Launcher

{activeVersion.isActive ? '활성' : '비활성'}

최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'}

배포 파일

{activeVersion.artifact}

다운로드 URL

{activeVersion.downloadUrl}

릴리즈 노트

{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}

) : (
등록된 런처 버전이 없습니다.
)}
); } function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppPipeline[]; loading: boolean; onOpen: () => void }) { return ( } />
{loading ? (
{Array.from({ length: 4 }).map((_, index) => (
))}
) : pipelines.length === 0 ? (
표시할 저장소가 없습니다.
) : (
{pipelines.slice(0, 8).map((pipeline) => (

{pipeline.repository}

{pipeline.message}

{pipeline.status} {pipeline.branch} {formatRelativeTime(pipeline.createdAt)}
))}
)}
); } function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow[]; loading: boolean; onMore: () => void }) { return ( { if (!isTrustedClick(event)) return; onMore(); }} title="사용자/세션으로 이동" aria-label="사용자/세션으로 이동" > } />
{loading ? ( Array.from({ length: 4 }).map((_, index) => (
)) ) : sessions.length === 0 ? (
표시할 사용자 세션이 없습니다.
) : null} {!loading && sessions.map((session) => (

{session.user}

{session.state}

{session.email} · {session.role}

{formatDateTime(session.lastSeen)}
))}
); } function DashboardLinkButton({ onClick, label, pageAgentAllowed = true }: { onClick: () => void; label: string; pageAgentAllowed?: boolean }) { return ( ); } function AppRegistryTable({ apps: dashboardApps, releases, loading, onOpen }: { apps: ApiApp[]; releases: ApiAppRelease[]; loading: boolean; onOpen: () => void; }) { const latestReleaseByApp = getLatestReleaseByApp(releases); return ( } />
{loading ? ( Array.from({ length: 5 }).map((_, index) => ( )) ) : dashboardApps.length === 0 ? ( ) : 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 ( ); })}
앱 상태 릴리즈 저장소/경로 워크로드 구성요소
등록된 앱이 없습니다.

{app.name}

{app.key} · {app.namespace ?? '네임스페이스 없음'}

{app.status} {latestRelease ? `${latestRelease.version} · ${formatJobStatus(latestRelease.status)}` : '-'} {firstRepository ?? '-'} {firstRepository ? : null} {workloadCount} {app.components?.length ?? 0}
); } function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) { return ( } />
{loading ? ( Array.from({ length: 3 }).map((_, index) => (
)) ) : resources.length === 0 ? (
표시할 런타임 리소스가 없습니다.
) : null} {!loading && resources.slice(0, 8).map((item) => (

{item.kind}/{item.name}

{item.status}

{item.appName} · {item.namespace}

{item.cpu ? 'CPU' : 'Ready'} {item.cpu || item.ready || '-'}
{item.memory ? '메모리' : '재시작'} {item.memory || (item.restarts ?? '-')}
0 ? 'amber' : 'primary'} />
))}
); } function NoticePanel({ notices: dashboardNotices, loading, onOpen }: { notices: ApiNotice[]; loading: boolean; onOpen: () => void }) { return ( } />
{loading ? ( Array.from({ length: 4 }).map((_, index) => (
)) ) : dashboardNotices.length === 0 ? (
등록된 공지사항이 없습니다.
) : null} {!loading && dashboardNotices.slice(0, 8).map((notice) => (
{notice.appKey} {notice.isActive ? '게시' : '비게시'}

{notice.title}

{notice.linkUrl || notice.body}

{formatDateTime(notice.createdAt)}
))}
); } function DashboardPage({ onLauncherVersionsOpen, onCommonBuildsOpen, onAppManagementOpen, onRuntimeLogsOpen, onNoticesOpen, onUserSessionsOpen }: { onLauncherVersionsOpen: () => void; onCommonBuildsOpen: () => void; onAppManagementOpen: () => void; onRuntimeLogsOpen: () => void; onNoticesOpen: () => void; onUserSessionsOpen: () => void; }) { const [launcherRows, setLauncherRows] = useState([]); const [dashboardApps, setDashboardApps] = useState([]); const [dashboardReleases, setDashboardReleases] = useState([]); const [recentSessions, setRecentSessions] = useState([]); const [activeWorkSummary, setActiveWorkSummary] = useState({ builds: 0, releases: 0, deployments: 0 }); const [commonAppPipelines, setCommonAppPipelines] = useState([]); const [dashboardNotices, setDashboardNotices] = useState([]); const [runtimeResources, setRuntimeResources] = useState([]); 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(); 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 ( <>
); } 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(() => 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 ; case 'commonBuilds': return ; case 'commonComponents': return ; case 'commonLogs': return ; case 'launcherVersions': return ; case 'launcherComponents': return ; case 'launcherLogs': return ; case 'launcherSources': return ; case 'launcherBuilds': return ; case 'launcherApps': return ; case 'notices': return ; case 'adminManagement': if (!isOwner) return null; return ; case 'organizations': return ; case 'userSessions': return ; case 'licenses': return ; case 'appManagement': return ; case 'appRepositories': return ; case 'appSources': return ; case 'appBuilds': return ; case 'appReleases': return ; case 'appDeployments': return ; case 'appMigrations': return ; case 'gatewayStatus': return ; case 'gatewayAgents': return ; case 'gatewayDevices': return ; case 'tailscaleStatus': return ; case 'tailscaleRoutes': return ; case 'tailscaleAcl': return ; case 'agentRegistration': return ; case 'agentInstallStatus': return ; case 'agentSshKeys': return ; case 'agentWorkspace': return ; case 'agentCommands': return ; case 'runtimeConfig': return ; case 'runtimeLogs': return ; case 'runtimeDeviceControl': return ; default: return ( changeView('launcherVersions')} onCommonBuildsOpen={() => changeView('commonBuilds')} onAppManagementOpen={() => changeView('appManagement')} onRuntimeLogsOpen={() => changeView('runtimeLogs')} onNoticesOpen={() => changeView('notices')} onUserSessionsOpen={() => changeView('userSessions')} /> ); } }; if (!isAuthenticated) { return { setCurrentAdmin(admin); setIsAuthenticated(true); }} />; } return (
{renderView()}
); } export default App;