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 = { 대시보드: '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 = { 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; 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>({}); 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 ( ); } function PageTitle({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description: string; actions?: ReactNode; }) { return (

{eyebrow}

{title}

{description}

{actions ?
{actions}
: null}
); } function LoadingModal({ open, label = '데이터를 불러오는 중입니다.' }: { open: boolean; label?: string }) { if (!open) return null; return (

{label}

); } function useMinimumLoading(loading: boolean, minimumMs = 500) { const [visible, setVisible] = useState(loading); const startedAtRef = useRef(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(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

관리자 로그인

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

{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, 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 (
{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 }: { pipelines: CommonAppPipeline[]; loading: boolean }) { return (
{loading ? (
{Array.from({ length: 4 }).map((_, index) => (
))}
) : pipelines.length === 0 ? (
표시할 저장소가 없습니다.
) : (
{pipelines.slice(0, 8).map((pipeline) => (

{pipeline.repository}

{pipeline.message}

{pipeline.url ? ( ) : null}
{pipeline.status} {pipeline.branch} {formatRelativeTime(pipeline.createdAt)}
))}
)}
); } function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow[]; loading: boolean; onMore: () => void }) { return ( } />
{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 }: { onClick: () => void; label: string }) { 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 LauncherManagementPage() { const [launcherRows, setLauncherRows] = useState([]); const [launcherRepos, setLauncherRepos] = useState([]); 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 ( <> } /> {launcherError ? (
런처 버전 데이터를 불러오지 못했습니다.
) : null} {!launcherLoading && !launcher ? ( 등록된 런처 버전이 없습니다. ) : null} {launcher ? (

Autonomy Studio Launcher

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

최신 {launcher.version} · 최소 지원 {launcher.minimumVersion || '-'}

배포 파일

{launcher.artifact}

다운로드 URL

{launcher.downloadUrl}

소스 연결

{launcher.repository ? (

Repository

{launcher.repository}

Branch

{launcher.branch || '-'}

Commit

{launcher.commitSha || '-'}

Clone URL

{getHttpCloneUrl(launcher.repository)}

) : (

연결된 Gitea 저장소가 없습니다.

)}

릴리즈 노트

{launcher.releaseNotes || '-'}

{[ ['최신 버전', launcher.version], ['최소 런처 버전', launcher.minimumVersion || '-'] ].map(([label, value]) => (
{label} {value}
))}
) : null} {sourceModalOpen && launcher ? (

런처 소스 연결

{sourceSaveStatus === 'error' ?

런처 소스 연결 저장에 실패했습니다.

: null}
) : null} {releaseModalOpen ? (

릴리즈 등록