Files
studio-admin-fe/src/App.tsx.backup-before-page-split
T
2026-07-01 17:34:15 +09:00

4511 lines
188 KiB
Plaintext

import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import {
Activity,
Bell,
ChevronDown,
ChevronRight,
CircleHelp,
ExternalLink,
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 { cn } from './lib/utils';
import { getStoredSelectedAppId, setStoredSelectedAppId } from './lib/selected-app';
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 { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
import { RuntimeLogsPage as RuntimeLogsApiPage } from './pages/runtime/RuntimeLogsPage';
type ViewKey =
| 'dashboard'
| 'launcherVersions'
| 'launcherApps'
| 'launcherCommonSettings'
| 'notices'
| 'appManagement'
| 'appSources'
| 'appBuilds'
| 'appReleases'
| 'appDeployments'
| 'appMigrations'
| 'gatewayStatus'
| 'gatewayAgents'
| 'gatewayDevices'
| 'tailscaleStatus'
| 'tailscaleRoutes'
| 'tailscaleAcl'
| 'tailscaleDiagnostics'
| 'agentRegistration'
| 'agentInstallStatus'
| 'agentSshKeys'
| 'agentWorkspace'
| 'agentCommands'
| 'runtimeConfig'
| 'runtimeLogs'
| 'runtimeDeviceControl'
| 'adminManagement'
| 'userSessions'
| 'licenses';
const viewByLabel: Record<string, ViewKey> = {
대시보드: 'dashboard',
'버전 관리': 'launcherVersions',
'앱 연결': 'launcherApps',
'공통 설정': 'launcherCommonSettings',
공지사항: 'notices',
'앱 관리': 'appManagement',
'소스 관리': 'appSources',
'빌드 관리': 'appBuilds',
'릴리즈 관리': 'appReleases',
'배포 관리': 'appDeployments',
마이그레이션: 'appMigrations',
'연결 상태': 'gatewayStatus',
'Agent 관리': 'gatewayAgents',
'장비 동기화': 'gatewayDevices',
'노드 현황': 'tailscaleStatus',
라우트: 'tailscaleRoutes',
ACL: 'tailscaleAcl',
'연결 진단': 'tailscaleDiagnostics',
'장비 등록': 'agentRegistration',
'설치 상태': 'agentInstallStatus',
'SSH 키 배포': 'agentSshKeys',
'Workspace 설정': 'agentWorkspace',
'원격 명령': 'agentCommands',
'런타임 구성': 'runtimeConfig',
'로그/이벤트': 'runtimeLogs',
'장비 제어': 'runtimeDeviceControl',
'관리자 관리': 'adminManagement',
'사용자/세션': 'userSessions',
라이센스: 'licenses'
};
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 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 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 = {
활성: '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';
}
type NavGroup = (typeof navGroups)[number];
type NavItem = NavGroup['items'][number];
function Sidebar({
activeView,
menuSearch,
onViewChange
}: {
activeView: ViewKey;
menuSearch: string;
onViewChange: (view: ViewKey) => void;
}) {
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
const normalizedMenuSearch = menuSearch.trim().toLowerCase();
const visibleGroups = normalizedMenuSearch
? navGroups
.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)
: navGroups;
return (
<aside className="fixed inset-y-0 left-0 hidden w-72 flex-col border-r border-border bg-sidebar px-3 py-4 lg:flex">
<div className="mb-8 flex shrink-0 justify-center px-4 py-3">
<img className="h-5 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
</div>
<nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
{visibleGroups.map((group) => (
<div key={group.label}>
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
<div className="space-y-1">
{group.items.map((item) => {
const hasChildren = 'children' in item && item.children;
const isMenuOpen = Boolean(normalizedMenuSearch) || openMenus[item.label];
const isParentActive = hasChildren
? item.children.some((child) => viewByLabel[child.label] === activeView)
: activeView === viewByLabel[item.label];
return (
<div key={item.label}>
<button
type="button"
onClick={() => {
if (hasChildren) {
setOpenMenus((prev) => ({ ...prev, [item.label]: !prev[item.label] }));
return;
}
const view = viewByLabel[item.label];
if (view) onViewChange(view);
}}
className={cn(
'flex h-9 w-full items-center gap-2 rounded-md px-2 text-sm 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 === viewByLabel[child.label];
return (
<button
type="button"
key={child.label}
onClick={() => {
const view = viewByLabel[child.label];
if (view) onViewChange(view);
}}
className={cn(
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-xs font-bold text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
isChildActive && 'text-white'
)}
>
<span className={cn('h-3 w-1 rounded-full', isChildActive ? 'bg-white' : 'bg-transparent')} />
<span>{child.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
</div>
))}
{visibleGroups.length === 0 ? (
<div className="px-2 py-6 text-center text-xs text-muted-foreground">검색된 메뉴가 없습니다.</div>
) : null}
</nav>
</aside>
);
}
function PageTitle({
eyebrow,
title,
description,
actions
}: {
eyebrow: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm text-muted-foreground">{eyebrow}</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight md:text-3xl">{title}</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
);
}
function LoadingModal({ open, label = '데이터를 불러오는 중입니다.' }: { open: boolean; label?: string }) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/45 px-4 backdrop-blur-[2px]" role="status" aria-live="polite">
<div className="flex min-w-56 flex-col items-center rounded-lg border border-border bg-card px-6 py-5 shadow-xl">
<div className="size-9 animate-spin rounded-full border-2 border-muted border-t-primary" />
<p className="mt-3 text-sm font-medium text-foreground">{label}</p>
</div>
</div>
);
}
function useMinimumLoading(loading: boolean, minimumMs = 800) {
const [visible, setVisible] = useState(loading);
const startedAtRef = useRef<number | null>(loading ? Date.now() : null);
useEffect(() => {
if (loading) {
startedAtRef.current = Date.now();
setVisible(true);
return;
}
if (!visible) return;
const elapsed = startedAtRef.current ? Date.now() - startedAtRef.current : minimumMs;
const remaining = Math.max(minimumMs - elapsed, 0);
const timer = window.setTimeout(() => {
startedAtRef.current = null;
setVisible(false);
}, remaining);
return () => window.clearTimeout(timer);
}, [loading, minimumMs, visible]);
return visible;
}
function Topbar({
menuSearch,
onMenuSearchChange
}: {
menuSearch: string;
onMenuSearchChange: (value: string) => void;
}) {
return (
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-border bg-background/90 px-4 backdrop-blur-xl md:px-6">
<div className="flex min-w-0 items-center gap-3">
<Button variant="outline" className="lg:hidden">
<SlidersHorizontal className="size-4" />
</Button>
<div className="relative hidden w-80 md:block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="메뉴 검색"
value={menuSearch}
onChange={(event) => onMenuSearchChange(event.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" className="size-9 px-0">
<CircleHelp className="size-4" />
</Button>
<Button variant="ghost" className="size-9 px-0">
<Bell className="size-4" />
</Button>
<Button variant="outline" className="gap-2">
<span className="flex size-6 items-center justify-center rounded bg-muted text-xs font-semibold">AD</span>
<span className="hidden md:inline">관리자</span>
<ChevronDown className="size-4 text-muted-foreground" />
</Button>
</div>
</header>
);
}
function LoginPage({ onLogin }: { onLogin: () => void }) {
const [email, setEmail] = useState('admin@futuredrive.net');
const [password, setPassword] = useState('');
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const passwordInputRef = useRef<HTMLInputElement | null>(null);
const passwordMask = '•'.repeat(password.length);
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));
setLoginLoading(false);
onLogin();
})
.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-md items-center justify-center py-10">
<div className="w-full rounded-lg border border-border bg-card/95 shadow-2xl">
<div className="border-b border-border px-7 py-6">
<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-4 px-7 py-6" onSubmit={submitLogin}>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">이메일</span>
<input
type="email"
className="login-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={{ color: '#ffffff', WebkitTextFillColor: '#ffffff', backgroundColor: '#111827' }}
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">비밀번호</span>
<div
className="relative mt-2 h-10 w-full overflow-hidden rounded-md border border-input bg-[#111827] focus-within:ring-2 focus-within:ring-ring"
onClick={() => passwordInputRef.current?.focus()}
>
<div
className="pointer-events-none absolute inset-0 flex items-center px-3 text-sm tracking-[0.2em]"
style={{ color: '#ffffff', WebkitTextFillColor: '#ffffff' }}
>
{passwordMask}
</div>
<input
ref={passwordInputRef}
id="password"
name="admin-password-field"
type="text"
autoComplete="off"
spellCheck={false}
className="absolute inset-0 h-full w-full border-0 bg-transparent px-3 text-sm outline-none"
style={{ color: 'transparent', WebkitTextFillColor: 'transparent', caretColor: '#ffffff' }}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
</label>
{loginError ? (
<p className="text-center text-xs text-rose-300">이메일 또는 비밀번호를 확인해 주세요.</p>
) : null}
<button
type="submit"
className="mt-3 h-10 w-full rounded-md text-sm font-bold transition-colors disabled:pointer-events-none disabled:opacity-45"
style={{ backgroundColor: '#2fbda0', color: '#ffffff', WebkitTextFillColor: '#ffffff' }}
disabled={!email.trim() || !password.trim() || loginLoading}
>
<span style={{ color: '#ffffff', WebkitTextFillColor: '#ffffff' }}>
{loginLoading ? '확인 중' : '로그인'}
</span>
</button>
</form>
</div>
</div>
</div>
);
}
function MetricCards() {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{metrics.map((metric) => (
<Card key={metric.label} className="p-5">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<Badge tone={metric.delta.startsWith('-') ? 'red' : 'green'}>{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() {
return (
<Card className="xl:col-span-3">
<CardHeader
title="런처 버전 관리"
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
action={
<Button variant="outline">
<Settings2 className="size-4" />
배포 정책
</Button>
}
/>
<div className="grid gap-4 p-5 lg:grid-cols-[1fr_280px]">
<div className="space-y-4">
{launcherVersions.map((item) => (
<div key={item.appKey} 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">{item.name}</p>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
최신 {item.latestVersion} · 최소 지원 {item.minimumSupportedVersion}
</p>
</div>
</div>
<div className="mt-4 grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">배포 파일</p>
<p className="mt-1 truncate font-medium">{item.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 truncate font-medium text-muted-foreground">{item.downloadUrl}</p>
</div>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.releaseNotes}</p>
</div>
))}
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-sm font-medium">운영 알림</p>
<div className="mt-4 space-y-3">
{alerts.map((alert) => (
<div key={alert.label} className="flex gap-3 rounded-md bg-muted/30 p-3">
<alert.icon className="mt-0.5 size-4 text-muted-foreground" />
<p className="text-sm leading-5">{alert.label}</p>
</div>
))}
</div>
</div>
</div>
</Card>
);
}
function SessionsPanel({ onMore }: { onMore: () => void }) {
const [recentSessions, setRecentSessions] = useState<UserSessionRow[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
useEffect(() => {
loadUserSessions()
.then((rows) => {
setRecentSessions(rows.slice(0, 5));
setSessionsLoading(false);
})
.catch(() => {
setRecentSessions([]);
setSessionsLoading(false);
});
}, []);
return (
<Card>
<CardHeader
title="사용자/세션"
description="실시간 접근 상태와 세션 제어 항목입니다."
action={
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={onMore}>
더보기
</Button>
}
/>
<div className="divide-y divide-border">
{sessionsLoading ? (
<div className="flex items-center justify-center gap-3 px-5 py-8 text-sm text-muted-foreground">
<div className="size-5 animate-spin rounded-full border-2 border-muted border-t-primary" />
<span>불러오는 중</span>
</div>
) : recentSessions.length === 0 ? (
<div className="px-5 py-6 text-center text-sm text-muted-foreground">표시할 사용자 세션이 없습니다.</div>
) : null}
{!sessionsLoading && recentSessions.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 AppRegistryTable() {
return (
<Card>
<CardHeader
title="앱 관리"
description="런처 앱별 활성화, 로컬 실행 경로, 릴리즈, 워크로드, 시크릿, 환경변수를 관리합니다."
action={
<Button>
<Plus className="size-4" />
앱 등록
</Button>
}
/>
<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>
<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">
{apps.map((app) => (
<tr key={app.key} className="hover:bg-muted/20">
<td className="px-5 py-4">
<p className="font-medium">{app.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{app.key} · {app.version}</p>
</td>
<td className="px-5 py-4">
<Badge tone={getTone(app.status)}>{app.status}</Badge>
</td>
<td className="px-5 py-4 text-muted-foreground">{app.release}</td>
<td className="px-5 py-4">
<span className="inline-flex items-center gap-1 text-muted-foreground">
{app.gitea}
<ExternalLink className="size-3" />
</span>
</td>
<td className="px-5 py-4">{app.containers}</td>
<td className="px-5 py-4">{app.secrets}</td>
<td className="px-5 py-4">
<div className="w-32 space-y-2">
<Progress value={app.cpu} tone={app.cpu > 70 ? 'amber' : 'primary'} />
<Progress value={app.memory} tone={app.memory > 75 ? 'red' : 'green'} />
</div>
</td>
<td className="px-5 py-4 text-right">
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
function ContainerPanel() {
return (
<Card>
<CardHeader title="런타임 상태" description="워크로드 상태, 이미지 버전, 리소스 사용량을 확인합니다." />
<div className="max-h-[360px] divide-y divide-border overflow-y-auto">
{k3sWorkloads.map((item) => (
<div key={item.pod} 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.workload}</p>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">{item.app} · {item.node}</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>CPU</span>
<span>{item.cpu}%</span>
</div>
<Progress value={item.cpu} tone={item.cpu > 80 ? 'red' : item.cpu > 65 ? 'amber' : 'primary'} />
</div>
<div>
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
<span>메모리</span>
<span>{item.memory}%</span>
</div>
<Progress value={item.memory} tone={item.memory > 80 ? 'red' : item.memory > 65 ? 'amber' : 'green'} />
</div>
</div>
</div>
))}
</div>
</Card>
);
}
function NoticePanel() {
return (
<Card>
<CardHeader
title="공지사항"
description="런처 공지와 앱별 공지사항을 관리합니다."
action={
<Button variant="outline">
<Plus className="size-4" />
공지 작성
</Button>
}
/>
<div className="max-h-[360px] divide-y divide-border overflow-y-auto">
{notices.map((notice) => (
<div key={notice.title} 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.scope === '런처' ? 'violet' : 'blue'}>{notice.scope}</Badge>
<Badge tone={getTone(notice.status)}>{notice.status}</Badge>
</div>
<p className="mt-2 truncate text-sm font-medium">{notice.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{notice.audience}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{notice.date}</span>
</div>
))}
</div>
</Card>
);
}
function LauncherManagementPage() {
const [launcherRows, setLauncherRows] = useState<ApiLauncherVersion[]>([]);
const [launcherRepos, setLauncherRepos] = useState<GiteaRepo[]>([]);
const [launcherLoading, setLauncherLoading] = useState(true);
const launcherLoadingVisible = useMinimumLoading(launcherLoading);
const [launcherError, setLauncherError] = useState(false);
const [sourceModalOpen, setSourceModalOpen] = useState(false);
const [sourceRepository, setSourceRepository] = useState('');
const [sourceBranch, setSourceBranch] = useState('release');
const [sourceCommitSha, setSourceCommitSha] = useState('');
const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
const [releaseSaveStatus, setReleaseSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [newLauncherRelease, setNewLauncherRelease] = useState({
version: '',
minimumVersion: '',
artifact: '',
downloadUrl: '',
releaseNotes: '',
repository: '',
branch: 'release',
commitSha: ''
});
const launcher = launcherRows[0];
const refreshLauncherVersions = () => {
setLauncherLoading(true);
loadLauncherVersions()
.then((versions) => {
setLauncherRows(versions);
setLauncherError(false);
setLauncherLoading(false);
})
.catch(() => {
setLauncherRows([]);
setLauncherError(true);
setLauncherLoading(false);
});
};
useEffect(() => {
let mounted = true;
refreshLauncherVersions();
loadPlatformRepos()
.then((response) => {
if (!response.ok) throw new Error('Gitea repositories request failed');
return response.json() as Promise<{ repos: GiteaRepo[] }>;
})
.then((data) => {
if (!mounted) return;
setLauncherRepos(data.repos ?? []);
})
.catch(() => {
if (!mounted) return;
setLauncherRepos([]);
});
return () => {
mounted = false;
};
}, []);
const openSourceModal = () => {
if (!launcher) return;
setSourceRepository(launcher.repository ?? launcherRepos[0]?.fullName ?? '');
setSourceBranch(launcher.branch ?? 'release');
setSourceCommitSha(launcher.commitSha ?? '');
setSourceSaveStatus('idle');
setSourceModalOpen(true);
};
const saveLauncherSource = () => {
if (!launcher || !sourceRepository.trim()) return;
setSourceSaveStatus('saving');
fetch(`${apiBaseUrl}/launcher/versions/${launcher.id}/source`, {
method: 'PATCH',
headers: { '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');
fetch(`${apiBaseUrl}/launcher/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
version: newLauncherRelease.version.trim(),
minimumVersion: newLauncherRelease.minimumVersion.trim() || undefined,
artifact: newLauncherRelease.artifact.trim(),
downloadUrl: newLauncherRelease.downloadUrl.trim(),
releaseNotes: newLauncherRelease.releaseNotes.trim() || undefined,
repository: newLauncherRelease.repository || undefined,
branch: newLauncherRelease.branch || undefined,
commitSha: newLauncherRelease.commitSha.trim() || undefined
})
})
.then((response) => {
if (!response.ok) throw new Error('Launcher release create failed');
return response.json();
})
.then(() => {
setReleaseModalOpen(false);
setReleaseSaveStatus('idle');
refreshLauncherVersions();
})
.catch(() => setReleaseSaveStatus('error'));
};
return (
<>
<PageTitle
eyebrow="런처 운영"
title="버전 관리"
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태만 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={openSourceModal} disabled={!launcher}>
<Settings2 className="size-4" />
소스 연결
</Button>
<Button className="h-8 px-3 text-[13px]" onClick={openReleaseModal}>
<Plus className="size-4" />런처 릴리즈 등록
</Button>
</>
}
/>
{launcherError ? (
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
런처 버전 데이터를 불러오지 못했습니다.
</div>
) : null}
{!launcherLoading && !launcher ? (
<Card className="px-5 py-8 text-center text-sm text-muted-foreground">
등록된 런처 버전이 없습니다.
</Card>
) : null}
{launcher ? (
<div className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
<Card>
<CardHeader title="메인 런처 배포 상태" description="앱 실행의 진입점인 런처 자체 버전과 배포 파일 상태입니다." />
<div className="p-5">
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/20 p-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
<p className="text-base font-semibold">Autonomy Studio Launcher</p>
<Badge tone={launcher.isActive ? 'green' : 'neutral'}>{launcher.isActive ? '활성' : '비활성'}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
최신 {launcher.version} · 최소 지원 {launcher.minimumVersion || '-'}
</p>
</div>
</div>
<div className="mt-4 grid gap-3">
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">배포 파일</p>
<p className="mt-2 truncate text-sm font-semibold">{launcher.artifact}</p>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<p className="mt-2 truncate text-sm font-semibold text-muted-foreground">{launcher.downloadUrl}</p>
</div>
</div>
<div className="mt-4 rounded-md border border-border bg-background p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">소스 연결</p>
<Button variant="ghost" className="h-7 px-2 text-xs" onClick={openSourceModal}>변경</Button>
</div>
{launcher.repository ? (
<div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">Repository</p>
<p className="mt-1 truncate font-semibold">{launcher.repository}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Branch</p>
<p className="mt-1 truncate font-semibold">{launcher.branch || '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Commit</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{launcher.commitSha || '-'}</p>
</div>
<div className="md:col-span-3">
<p className="text-xs text-muted-foreground">Clone URL</p>
<div className="mt-1 flex items-center gap-2">
<p className="truncate font-mono text-xs text-muted-foreground">{getHttpCloneUrl(launcher.repository)}</p>
<button
type="button"
title="HTTP clone URL 복사"
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => copyToClipboard(getHttpCloneUrl(launcher.repository!))}
>
<Copy className="size-3.5" />
</button>
</div>
</div>
</div>
) : (
<p className="mt-3 text-sm text-muted-foreground">연결된 Gitea 저장소가 없습니다.</p>
)}
</div>
<div className="mt-4 rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<p className="mt-2 text-sm leading-6">{launcher.releaseNotes || '-'}</p>
</div>
</div>
</Card>
<Card>
<CardHeader title="업데이트 기준" description="런처 버전 판정에 필요한 최소 기준입니다." />
<div className="grid gap-3 p-5">
{[
['최신 버전', launcher.version],
['최소 런처 버전', launcher.minimumVersion || '-']
].map(([label, value]) => (
<div key={label} className="flex items-center justify-between gap-4 rounded-md border border-border bg-muted/20 px-4 py-3">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-right text-sm font-semibold">{value}</span>
</div>
))}
</div>
</Card>
</div>
) : null}
{sourceModalOpen && launcher ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold">런처 소스 연결</h2>
</div>
<div className="space-y-4 px-5 py-5">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Repository</span>
<select
value={sourceRepository}
onChange={(event) => setSourceRepository(event.target.value)}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
>
{launcherRepos.length === 0 ? <option value="">platform 저장소 없음</option> : null}
{launcherRepos.map((repo) => (
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Branch</span>
<select
value={sourceBranch}
onChange={(event) => setSourceBranch(event.target.value)}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
>
<option value="release">release</option>
<option value="develop">develop</option>
<option value="main">main</option>
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
<input
value={sourceCommitSha}
onChange={(event) => setSourceCommitSha(event.target.value)}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
{sourceSaveStatus === 'error' ? <p className="text-xs text-rose-300">런처 소스 연결 저장에 실패했습니다.</p> : null}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={() => setSourceModalOpen(false)}>취소</Button>
<Button onClick={saveLauncherSource} disabled={!sourceRepository || sourceSaveStatus === 'saving'}>
{sourceSaveStatus === 'saving' ? '저장 중' : '저장'}
</Button>
</div>
</div>
</div>
) : null}
{releaseModalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-2xl rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold">런처 릴리즈 등록</h2>
</div>
<div className="grid max-h-[72vh] gap-4 overflow-y-auto px-5 py-5 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">버전</span>
<input
value={newLauncherRelease.version}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, version: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">최소 런처 버전</span>
<input
value={newLauncherRelease.minimumVersion}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, minimumVersion: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block md:col-span-2">
<span className="text-xs font-medium text-muted-foreground">배포 파일</span>
<input
value={newLauncherRelease.artifact}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, artifact: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block md:col-span-2">
<span className="text-xs font-medium text-muted-foreground">다운로드 URL</span>
<input
value={newLauncherRelease.downloadUrl}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, downloadUrl: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Repository</span>
<select
value={newLauncherRelease.repository}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, repository: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
>
<option value="">미연결</option>
{launcherRepos.map((repo) => (
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Branch</span>
<select
value={newLauncherRelease.branch}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, branch: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
>
<option value="release">release</option>
<option value="develop">develop</option>
<option value="main">main</option>
</select>
</label>
<label className="block md:col-span-2">
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
<input
value={newLauncherRelease.commitSha}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, commitSha: event.target.value }))}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block md:col-span-2">
<span className="text-xs font-medium text-muted-foreground">릴리즈 노트</span>
<textarea
value={newLauncherRelease.releaseNotes}
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, releaseNotes: event.target.value }))}
className="mt-2 min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
/>
</label>
{releaseSaveStatus === 'error' ? (
<p className="md:col-span-2 text-xs text-rose-300">런처 릴리즈 등록에 실패했습니다. 버전 중복과 필수값을 확인하세요.</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={() => setReleaseModalOpen(false)}>취소</Button>
<Button
onClick={createLauncherRelease}
disabled={
!newLauncherRelease.version.trim() ||
!newLauncherRelease.artifact.trim() ||
!newLauncherRelease.downloadUrl.trim() ||
releaseSaveStatus === 'saving'
}
>
{releaseSaveStatus === 'saving' ? '등록 중' : '등록'}
</Button>
</div>
</div>
</div>
) : null}
<LoadingModal open={launcherLoadingVisible} />
</>
);
}
function AppManagementPage() {
const { toast } = useToast();
const [apiApps, setApiApps] = useState<ApiApp[]>([]);
const [isAppsLoading, setIsAppsLoading] = useState(true);
const appsLoadingVisible = useMinimumLoading(isAppsLoading);
const [selectedAppId, setSelectedAppId] = useState(() => getStoredSelectedAppId());
const [isCreateAppOpen, setIsCreateAppOpen] = useState(false);
const [newAppKey, setNewAppKey] = useState('');
const [newAppName, setNewAppName] = useState('');
const [newAppNamespace, setNewAppNamespace] = useState('');
const [newAppDescription, setNewAppDescription] = useState('');
const [appCreateStatus, setAppCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const selectedApp = apiApps.find((app) => app.id === selectedAppId) ?? apiApps[0];
const totalComponents = selectedApp?.components?.length ?? 0;
const linkedComponents = selectedApp?.components?.filter((component) => component.repository).length ?? 0;
useEffect(() => {
setIsAppsLoading(true);
loadApps()
.then((apps) => {
setApiApps(apps);
setSelectedAppId((current) => {
const storedAppId = getStoredSelectedAppId();
const preferredAppId = storedAppId || current;
const nextAppId = apps.some((app) => app.id === preferredAppId) ? preferredAppId : apps[0]?.id || '';
if (nextAppId) setStoredSelectedAppId(nextAppId);
return nextAppId;
});
setIsAppsLoading(false);
})
.catch(() => {
setApiApps([]);
setIsAppsLoading(false);
});
}, []);
const selectApp = (appId: string) => {
setSelectedAppId(appId);
setStoredSelectedAppId(appId);
};
const createApp = () => {
if (!newAppKey.trim() || !newAppName.trim()) return;
const namespace = newAppNamespace.trim();
setAppCreateStatus('saving');
fetch(`${apiBaseUrl}/apps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: newAppKey,
name: newAppName,
namespace: namespace || undefined,
description: newAppDescription || undefined
})
})
.then((response) => {
if (!response.ok) throw new Error('App create failed');
return response.json();
})
.then((result) => {
if (!namespace) return result;
return fetch(`${apiBaseUrl}/apps/runtime-namespaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace })
})
.then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null);
const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message;
throw new Error(message || 'Namespace create failed');
}
return response.json();
})
.then((namespaceResult) => {
toast({
type: 'success',
title: 'Namespace 생성/확인',
message: namespaceResult.created ? `${namespaceResult.namespace} namespace를 생성했습니다.` : `${namespaceResult.namespace} namespace가 이미 있습니다.`
});
return result;
})
.catch((namespaceError: Error) => {
toast({
type: 'warning',
title: '앱 등록 완료 / Namespace 생성 실패',
message: namespaceError.message
});
return result;
});
})
.then(() => loadApps())
.then((apps) => {
setApiApps(apps);
setNewAppKey('');
setNewAppName('');
setNewAppNamespace('');
setNewAppDescription('');
setAppCreateStatus('idle');
setIsCreateAppOpen(false);
toast({ type: 'success', title: '앱 등록', message: '앱 그룹이 등록되었습니다.' });
})
.catch(() => {
setAppCreateStatus('error');
toast({ type: 'error', title: '앱 등록 실패', message: '중복 Key 또는 백엔드 상태를 확인하세요.' });
});
};
return (
<>
<PageTitle
eyebrow="앱 운영"
title="앱 관리"
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 fe, be, adm-fe, db, 워크로드를 하나의 묶음으로 운영합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]">
<Settings2 className="size-4" />
배포 템플릿
</Button>
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
<Plus className="size-4" />앱 등록
</Button>
</>
}
/>
<div className="space-y-4">
<Card className="min-w-0 overflow-hidden">
<CardHeader title="등록된 앱 그룹" description="앱 그룹을 먼저 등록한 뒤 구성요소와 Gitea 저장소를 연결합니다." />
<div className="max-h-[320px] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">Key</th>
<th className="px-5 py-3 font-medium">앱 이름</th>
<th className="px-5 py-3 font-medium">Namespace</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">
{!isAppsLoading && apiApps.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-6 text-center text-muted-foreground">등록된 앱 그룹이 없습니다.</td>
</tr>
) : (
apiApps.map((app) => (
<tr
key={app.id}
className={cn(
'cursor-pointer transition-colors hover:bg-muted/20',
selectedApp?.id === app.id && 'bg-primary/20 shadow-[inset_4px_0_0_var(--color-primary)] hover:bg-primary/25'
)}
onClick={() => selectApp(app.id)}
>
<td className="px-5 py-4 font-semibold">{app.key}</td>
<td className="px-5 py-4">{app.name}</td>
<td className="px-5 py-4 text-muted-foreground">{app.namespace || '-'}</td>
<td className="px-5 py-4">{app.components?.length ?? 0}</td>
<td className="px-5 py-4"><Badge tone={app.status === 'ACTIVE' ? 'green' : 'neutral'}>{app.status}</Badge></td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold">앱 선택</p>
<p className="mt-1 text-xs text-muted-foreground">관리할 앱 그룹을 선택합니다.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
value={selectedApp?.id ?? ''}
onChange={(event) => selectApp(event.target.value)}
>
{!isAppsLoading && apiApps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
{apiApps.map((app) => (
<option key={app.id} value={app.id}>
{app.name}
</option>
))}
</select>
{selectedApp ? (
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1">{selectedApp.namespace || '-'}</span>
<span className="rounded-md bg-muted px-2 py-1">구성요소 {selectedApp.components?.length ?? 0}</span>
<Badge tone={getTone(selectedApp.status)}>{selectedApp.status}</Badge>
</div>
) : null}
</div>
</div>
{!isAppsLoading && apiApps.length === 0 ? (
<div className="mt-4 rounded-md border border-dashed border-border bg-muted/20 px-4 py-5 text-center text-sm text-muted-foreground">
등록된 앱을 선택해주세요.
</div>
) : null}
</Card>
{selectedApp ? (
<div className="min-w-0 space-y-4">
<Card className="min-w-0 overflow-hidden">
<CardHeader
title={`${selectedApp.name} 구성 요약`}
description="런처 다운로드 대상과 함께 배포되는 서버/관리자/DB 구성입니다."
action={
<Button variant="outline">
<MoreHorizontal className="size-4" />
설정
</Button>
}
/>
<div className="grid gap-4 p-5 md:grid-cols-4">
{[
['Namespace', selectedApp.namespace],
['연결 소스', `${linkedComponents}개`],
['구성요소', `${totalComponents}개`],
['상태', selectedApp.status]
].map(([label, value]) => (
<div key={label} className="rounded-md border border-border bg-muted/20 p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-2 truncate text-sm font-semibold">{value}</p>
</div>
))}
</div>
<div className="border-t border-border px-5 py-4">
<div className="grid gap-3 text-sm md:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground">Key</p>
<p className="mt-1 font-semibold">{selectedApp.key}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">설명</p>
<p className="mt-1 truncate font-semibold text-muted-foreground">{selectedApp.description || '-'}</p>
</div>
</div>
</div>
</Card>
<Card>
<CardHeader
title="앱 구성요소"
description="런처가 다운로드할 클라이언트 산출물과 함께 운영할 워크로드입니다."
/>
<div className="max-h-[430px] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[1180px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">구성요소</th>
<th className="px-5 py-3 font-medium">역할</th>
<th className="px-5 py-3 font-medium">유형</th>
<th className="px-5 py-3 font-medium">워크로드</th>
<th className="px-5 py-3 font-medium">이미지/산출물</th>
<th className="px-5 py-3 font-medium">Replicas</th>
<th className="px-5 py-3 font-medium">소스</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{!isAppsLoading && (selectedApp.components ?? []).map((component) => (
<tr key={component.name} className="hover:bg-muted/20">
<td className="px-5 py-4 font-medium">
<span className="inline-flex items-center gap-2">
<span className={cn('size-2 rounded-full', component.repository ? 'bg-emerald-400' : 'bg-muted-foreground')} />
{component.name}
</span>
</td>
<td className="px-5 py-4 text-muted-foreground">{component.buildTarget || '-'}</td>
<td className="px-5 py-4">{component.type}</td>
<td className="px-5 py-4 text-muted-foreground">-</td>
<td className="px-5 py-4 text-muted-foreground">{component.repository || '미연결'}</td>
<td className="px-5 py-4">{component.branch || '-'}</td>
<td className="px-5 py-4 text-muted-foreground">{component.repository || '미연결'}</td>
</tr>
))}
{!isAppsLoading && (selectedApp.components ?? []).length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-6 text-center text-muted-foreground">등록된 구성요소가 없습니다. 소스 관리에서 구성요소를 추가하세요.</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
) : null}
</div>
{isCreateAppOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold">앱 그룹 등록</h2>
</div>
<div className="space-y-4 px-5 py-5">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">앱 Key</span>
<input value={newAppKey} onChange={(event) => setNewAppKey(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">앱 이름</span>
<input value={newAppName} onChange={(event) => setNewAppName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Namespace</span>
<input value={newAppNamespace} onChange={(event) => setNewAppNamespace(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">설명</span>
<textarea value={newAppDescription} onChange={(event) => setNewAppDescription(event.target.value)} className="mt-2 min-h-20 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary" />
</label>
{appCreateStatus === 'error' ? <p className="text-xs text-rose-300">앱 등록에 실패했습니다. 중복 Key 또는 백엔드 상태를 확인하세요.</p> : null}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={() => setIsCreateAppOpen(false)}>취소</Button>
<Button onClick={createApp} disabled={!newAppKey.trim() || !newAppName.trim() || appCreateStatus === 'saving'}>{appCreateStatus === 'saving' ? '등록 중' : '등록'}</Button>
</div>
</div>
</div>
) : null}
<LoadingModal open={appsLoadingVisible} />
</>
);
}
function ContainersPage() {
const [selectedAppName, setSelectedAppName] = useState(containerBlueprints[0]?.app ?? '');
const selectedBlueprint = containerBlueprints.find((item) => item.app === selectedAppName) ?? containerBlueprints[0];
const totalResources = selectedBlueprint?.resources.length ?? 0;
const secretCount = selectedBlueprint?.resources.reduce((sum, resource) => sum + resource.secrets, 0) ?? 0;
const envCount = selectedBlueprint?.resources.reduce((sum, resource) => sum + resource.env, 0) ?? 0;
const runtimeRows = selectedBlueprint
? k3sWorkloads.filter((workload) => workload.app === selectedBlueprint.app)
: [];
return (
<>
<PageTitle
eyebrow="플랫폼 런타임"
title="런타임 구성"
description="앱 실행에 필요한 서버 구성, 접속 경로, 설정값, 저장소를 한 묶음으로 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]">
<Settings2 className="size-4" />
템플릿
</Button>
<Button className="h-8 px-3 text-[13px]">
<Plus className="size-4" />
리소스 추가
</Button>
</>
}
/>
<div className="space-y-4">
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold">앱 선택</p>
<p className="mt-1 text-xs text-muted-foreground">런타임 구성을 확인할 앱 그룹을 선택합니다.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
value={selectedAppName}
onChange={(event) => setSelectedAppName(event.target.value)}
>
{containerBlueprints.map((blueprint) => (
<option key={blueprint.app} value={blueprint.app}>
{blueprint.app}
</option>
))}
</select>
{selectedBlueprint ? (
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1">{selectedBlueprint.namespace}</span>
<span className="rounded-md bg-muted px-2 py-1">{selectedBlueprint.release}</span>
<span className="rounded-md bg-muted px-2 py-1">리소스 {selectedBlueprint.resources.length}</span>
</div>
) : null}
</div>
</div>
</Card>
{selectedBlueprint ? (
<div className="min-w-0 space-y-4">
<div className="grid gap-4 md:grid-cols-4">
{[
['Namespace', selectedBlueprint.namespace],
['리소스', `${totalResources}개`],
['환경변수', `${envCount}개`],
['시크릿', `${secretCount}개`]
].map(([label, value]) => (
<Card key={label} className="p-5">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-3 truncate text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="리소스 구성"
description="현재 실행 중인 워크로드와 주요 제어 기능입니다."
action={
<span className="text-xs font-semibold text-muted-foreground">
총 {runtimeRows.length || totalResources}개
</span>
}
/>
<div className="border-b border-border px-5 py-4">
<div className="grid gap-3 lg:grid-cols-[220px_180px_minmax(0,1fr)]">
<label className="space-y-2 text-xs font-medium text-muted-foreground">
Namespace
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" defaultValue={selectedBlueprint.namespace}>
<option value={selectedBlueprint.namespace}>{selectedBlueprint.namespace}</option>
</select>
</label>
<label className="space-y-2 text-xs font-medium text-muted-foreground">
상태
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" defaultValue="">
<option value="">전체</option>
<option value="실행 중">실행 중</option>
<option value="주의">주의</option>
</select>
</label>
<label className="space-y-2 text-xs font-medium text-muted-foreground">
검색
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="리소스명 / 워크로드 / 노드 / 이미지"
/>
</div>
</label>
</div>
</div>
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[1180px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="w-12 px-5 py-3 font-medium">상태</th>
<th className="px-5 py-3 font-medium">리소스</th>
<th className="px-5 py-3 font-medium">워크로드</th>
<th className="px-5 py-3 font-medium">Ready</th>
<th className="px-5 py-3 font-medium">Restarts</th>
<th className="px-5 py-3 font-medium">Node</th>
<th className="px-5 py-3 font-medium">Age</th>
<th className="px-5 py-3 text-right font-medium">제어</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{(runtimeRows.length ? runtimeRows : selectedBlueprint.resources.map((resource) => ({
namespace: selectedBlueprint.namespace,
app: selectedBlueprint.app,
workload: `${resource.kind}/${resource.name}`,
pod: resource.name,
image: resource.image,
replicas: String(resource.replicas),
node: '-',
restarts: 0,
age: '-',
status: resource.status
}))).map((resource) => (
<tr key={`${resource.namespace}-${resource.pod}`} className="hover:bg-muted/20">
<td className="px-5 py-4">
<span
className={cn(
'block size-2.5 rounded-full',
resource.status === '실행 중' || resource.status === '구성완료'
? 'bg-emerald-400'
: resource.status === '주의'
? 'bg-amber-400'
: 'bg-muted-foreground'
)}
/>
</td>
<td className="px-5 py-4">
<p className="font-semibold">{resource.pod}</p>
<p className="mt-1 text-xs text-muted-foreground">{resource.image}</p>
</td>
<td className="px-5 py-4">
<p className="font-semibold">{resource.workload}</p>
<p className="mt-1 text-xs text-muted-foreground">{resource.namespace}</p>
</td>
<td className="px-5 py-4">{resource.replicas}</td>
<td className="px-5 py-4">{resource.restarts}</td>
<td className="px-5 py-4 text-muted-foreground">{resource.node}</td>
<td className="px-5 py-4 text-muted-foreground">{resource.age}</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
<Button variant="outline" className="h-8 px-3 text-[13px]">로그</Button>
<Button variant="secondary" className="h-8 px-3 text-[13px]">종료</Button>
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="grid gap-4 xl:grid-cols-3">
{[
['Service / Ingress', '외부 노출, 라우팅, TLS 설정'],
['ConfigMap / Env', '앱별 환경변수와 런타임 설정'],
['Secret / Storage', '민감정보와 PVC 볼륨 연결']
].map(([title, description]) => (
<Card key={title} className="p-5">
<p className="text-sm font-semibold">{title}</p>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
<Button variant="outline" className="mt-4 h-8 px-3 text-[13px]">
설정
</Button>
</Card>
))}
</div>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="최근 제어 작업" description="워크로드 재기동, 스케일 변경, 롤백 요청 이력입니다." />
<div className="divide-y divide-border">
{[
['LMS', 'lms-api', '재기동', 'Deployment/lms-api rollout restart', '완료', '2분 전'],
['LMS', 'lms-adm-web', '로그 조회', '최근 200 lines', '완료', '6분 전'],
['ADS IDE', 'ads-ide-api', '스케일 변경', 'replicas 1 -> 2', '실행 중', '9분 전'],
['LMS', 'lms-postgres', '이벤트 조회', 'StatefulSet/lms-postgres', '완료', '18분 전']
].map(([app, resource, action, detail, status, time], index) => (
<div key={`${app}-${resource}-${action}-${time}`} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 w-5 shrink-0 text-sm font-semibold text-muted-foreground">{index + 1}</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{action} · {resource}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{app} · {detail}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="text-xs text-muted-foreground">{time}</span>
<Badge tone={getTone(status)}>{status}</Badge>
</div>
</div>
))}
</div>
</Card>
</div>
) : null}
</div>
</>
);
}
function LauncherAppsPage() {
const linkedApps = apps
.filter((app) => app.launcherLinked)
.sort((a, b) => (a.launcherOrder ?? 999) - (b.launcherOrder ?? 999));
const unlinkedApps = apps.filter((app) => !app.launcherLinked);
return (
<>
<PageTitle
eyebrow="런처 운영"
title="앱 연결"
description="앱 구성에 등록된 제품 앱 중 런처 홈에 노출하고 실행할 앱을 연결합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />앱 연결</Button>}
/>
<div className="space-y-4">
<Card className="min-w-0 overflow-hidden">
<CardHeader title="연결된 앱" description="런처 홈에 표시할 앱과 노출 순서를 관리합니다." />
<div className="max-h-[520px] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="w-28 px-5 py-3 font-medium">노출순서</th>
<th className="px-5 py-3 font-medium">앱</th>
<th className="px-5 py-3 font-medium">릴리즈</th>
<th className="px-5 py-3 font-medium">실행 경로</th>
<th className="px-5 py-3 text-right font-medium">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{linkedApps.map((app) => (
<tr key={app.key} className="hover:bg-muted/20">
<td className="px-5 py-4 font-semibold">{app.launcherOrder ?? '-'}</td>
<td className="px-5 py-4 font-semibold">{app.name}</td>
<td className="px-5 py-4 text-muted-foreground">{app.release}</td>
<td className="px-5 py-4 text-muted-foreground">{app.launchUrl}</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<Button variant="outline" className="size-8 px-0" aria-label={`${app.name} 위로 이동`}>
<ChevronDown className="size-4 rotate-180" />
</Button>
<Button variant="outline" className="size-8 px-0" aria-label={`${app.name} 아래로 이동`}>
<ChevronDown className="size-4" />
</Button>
<Button variant="outline" className="h-8 px-3 text-[13px]">해제</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="미연결 앱" description="등록은 되었지만 런처에는 보이지 않는 앱입니다." />
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
{unlinkedApps.map((app) => (
<div key={app.key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{app.name}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{app.key} · {app.release}</p>
</div>
<Button className="h-8 px-3 text-[13px]">연결</Button>
</div>
))}
</div>
</Card>
</div>
</>
);
}
function GatewayStatusPage() {
return (
<>
<PageTitle eyebrow="플랫폼 게이트웨이" title="연결 상태" description="Headscale, Agent, 저장소, 런타임 명령 버스의 연결 상태를 확인합니다." />
<div className="grid gap-4 xl:grid-cols-2">
{gatewayConnections.map((item) => (
<Card key={item.name} className="p-5">
<div className="flex items-center justify-between gap-4">
<p className="truncate text-sm font-semibold">{item.name}</p>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
<p className="mt-2 text-xs text-muted-foreground">{item.role}</p>
<p className="mt-4 truncate text-sm font-medium">{item.endpoint}</p>
<p className="mt-1 text-xs text-muted-foreground">마지막 동기화 {item.lastSync}</p>
</Card>
))}
</div>
</>
);
}
function GatewayAgentsPage() {
return (
<>
<PageTitle eyebrow="플랫폼 게이트웨이" title="Agent 관리" description="플랫폼 게이트웨이가 수집한 Agent 등록 및 상태 정보를 관리합니다." />
<AgentDeviceTable title="Agent 목록" description="등록된 운영 장비 Agent입니다." />
</>
);
}
function GatewayDeviceSyncPage() {
return (
<>
<PageTitle eyebrow="플랫폼 게이트웨이" title="장비 동기화" description="계정, 장비, 워크스페이스 정보를 인증 서버와 Agent 상태 기준으로 동기화합니다." />
<div className="grid gap-4 xl:grid-cols-3">
{[
['계정/장비 매핑', 'Auth Backend -> Gateway', '활성'],
['워크스페이스 경로', 'Agent -> Gateway', '활성'],
['원격 실행 상태', 'Runtime -> Gateway', '주의']
].map(([title, flow, status]) => (
<Card key={title} className="p-5">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-semibold">{title}</p>
<Badge tone={getTone(status)}>{status}</Badge>
</div>
<p className="mt-3 text-xs text-muted-foreground">{flow}</p>
</Card>
))}
</div>
</>
);
}
function AgentDeviceTable({ title, description }: { title: string; description: string }) {
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader title={title} description={description} />
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">장비</th>
<th className="px-5 py-3 font-medium">계정</th>
<th className="px-5 py-3 font-medium">Workspace</th>
<th className="px-5 py-3 font-medium">SSH 키</th>
<th className="px-5 py-3 font-medium">Last Seen</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{agentDevices.map((device) => (
<tr key={device.name} className="hover:bg-muted/20">
<td className="px-5 py-4 font-medium">{device.name}</td>
<td className="px-5 py-4 text-muted-foreground">{device.account}</td>
<td className="px-5 py-4 text-muted-foreground">{device.workspace}</td>
<td className="px-5 py-4">{device.sshKey}</td>
<td className="px-5 py-4 text-muted-foreground">{device.lastSeen}</td>
<td className="px-5 py-4"><Badge tone={getTone(device.status)}>{device.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
function AgentRegistrationPage() {
return (
<>
<PageTitle eyebrow="Agent" title="장비 등록" description="플랫폼 장비와 계정 정보를 등록하고 Agent 연결을 준비합니다." />
<AgentDeviceTable title="등록 장비" description="Agent 설치 대상 장비입니다." />
</>
);
}
function AgentInstallStatusPage() {
return (
<>
<PageTitle eyebrow="Agent" title="설치 상태" description="Agent 설치, heartbeat, 상태 보고 현황을 확인합니다." />
<AgentDeviceTable title="설치 상태" description="장비별 Agent 상태입니다." />
</>
);
}
function AgentSshKeysPage() {
return (
<>
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="Agent가 장비에 배포한 SSH 키와 배포 상태를 관리합니다." />
<AgentDeviceTable title="SSH 키 상태" description="장비별 SSH 키 배포 상태입니다." />
</>
);
}
function AgentWorkspacePage() {
return (
<>
<PageTitle eyebrow="Agent" title="Workspace 설정" description="원격 실행과 저장소 연동에 사용할 장비별 워크스페이스 루트를 관리합니다." />
<AgentDeviceTable title="Workspace" description="장비별 작업 디렉터리 설정입니다." />
</>
);
}
function AgentCommandsPage() {
return (
<>
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 장비에 전달된 원격 명령과 실행 결과를 관리합니다." />
<Card>
<CardHeader title="명령 이력" description="최근 원격 명령 요청입니다." />
<div className="divide-y divide-border">
{runtimeCommands.map((item) => (
<div key={`${item.target}-${item.command}`} className="flex items-center justify-between gap-4 px-5 py-4">
<div>
<p className="text-sm font-semibold">{item.command}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.target} · {item.requestedBy} · {item.time}</p>
</div>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
))}
</div>
</Card>
</>
);
}
function NoticesPage() {
const noticeRows = notices.map((notice, index) => ({ ...notice, no: notices.length - index }));
type NoticeRow = (typeof noticeRows)[number];
const noticeColumns: Array<DataTableColumn<NoticeRow>> = [
{
key: 'no',
header: 'No.',
className: 'w-20 px-5 py-3 font-medium',
cell: (notice) => <span className="font-semibold">{notice.no}</span>
},
{
key: 'title',
header: '공지',
className: 'w-[320px] px-5 py-3 font-medium',
cell: (notice) => <span className="font-semibold">{notice.title}</span>
},
{
key: 'scope',
header: '대상 앱',
cell: (notice) => <Badge tone={notice.scope === '런처' ? 'violet' : 'blue'}>{notice.scope}</Badge>
},
{
key: 'audience',
header: '대상 사용자',
cell: (notice) => <span className="text-muted-foreground">{notice.audience}</span>
},
{
key: 'status',
header: '상태',
cell: (notice) => <Badge tone={getTone(notice.status)}>{notice.status}</Badge>
},
{
key: 'date',
header: '노출일',
cell: (notice) => <span className="text-muted-foreground">{notice.date}</span>
},
{
key: 'actions',
header: '',
className: 'w-20 px-5 py-3 font-medium',
cell: () => (
<div className="text-right">
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</div>
)
}
];
const noticeFilters: Array<DataTableFilter<NoticeRow>> = [
{
key: 'scope',
label: '대상 앱',
options: [...new Set(noticeRows.map((notice) => notice.scope))].map((scope) => ({ label: scope, value: scope })),
getValue: (notice) => notice.scope
},
{
key: 'status',
label: '상태',
options: [...new Set(noticeRows.map((notice) => notice.status))].map((status) => ({ label: status, value: status })),
getValue: (notice) => notice.status
}
];
return (
<>
<PageTitle
eyebrow="운영"
title="공지사항"
description="로그인 화면, 런처 홈, 앱별 영역에 노출할 공지사항을 관리합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />공지 작성</Button>}
/>
<DataTableCard
title="목록"
rows={noticeRows}
columns={noticeColumns}
filters={noticeFilters}
searchPlaceholder="공지 제목, 대상 사용자 검색"
getSearchText={(notice) => `${notice.title} ${notice.scope} ${notice.audience} ${notice.status}`}
getRowKey={(notice) => `${notice.scope}-${notice.title}-${notice.date}`}
pageSize={6}
minWidth={980}
/>
</>
);
}
function AdminManagementPage() {
const [adminRows, setAdminRows] = useState<AdminRow[]>([]);
const [adminLoading, setAdminLoading] = useState(true);
const adminLoadingVisible = useMinimumLoading(adminLoading);
const [adminError, setAdminError] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false);
const [passwordModal, setPasswordModal] = useState<{ admin: AdminRow | null; temporaryPassword: string }>({
admin: null,
temporaryPassword: ''
});
const [sshModal, setSshModal] = useState({
admin: null as AdminRow | null,
label: '',
giteaUsername: '',
publicKey: ''
});
const [newAdmin, setNewAdmin] = useState({
email: '',
name: '',
role: 'OPERATOR' as AdminRow['role'],
temporaryPassword: ''
});
const [adminCreateStatus, setAdminCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [passwordResetStatus, setPasswordResetStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [sshStatus, setSshStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [sshError, setSshError] = useState('');
const refreshAdmins = () => {
setAdminLoading(true);
loadAdmins()
.then((rows) => {
setAdminRows(rows);
setAdminError(false);
setAdminLoading(false);
})
.catch(() => {
setAdminRows([]);
setAdminError(true);
setAdminLoading(false);
});
};
useEffect(() => {
refreshAdmins();
}, []);
const createAdmin = () => {
setAdminCreateStatus('saving');
fetch(`${apiBaseUrl}/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAdmin)
})
.then((response) => {
if (!response.ok) throw new Error('Admin create failed');
return response.json();
})
.then(() => {
setAdminCreateStatus('idle');
setAdminModalOpen(false);
setNewAdmin({ email: '', name: '', role: 'OPERATOR', temporaryPassword: '' });
refreshAdmins();
})
.catch(() => setAdminCreateStatus('error'));
};
const updateAdminStatus = (admin: AdminRow, isActive: boolean) => {
fetch(`${apiBaseUrl}/admins/${admin.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive })
})
.then((response) => {
if (!response.ok) throw new Error('Admin status update failed');
refreshAdmins();
})
.catch(() => setAdminError(true));
};
const updateAdminRole = (admin: AdminRow, role: AdminRow['role']) => {
fetch(`${apiBaseUrl}/admins/${admin.id}/role`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role })
})
.then((response) => {
if (!response.ok) throw new Error('Admin role update failed');
refreshAdmins();
})
.catch(() => setAdminError(true));
};
const resetAdminPassword = () => {
if (!passwordModal.admin) return;
setPasswordResetStatus('saving');
fetch(`${apiBaseUrl}/admins/${passwordModal.admin.id}/password`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ temporaryPassword: passwordModal.temporaryPassword })
})
.then((response) => {
if (!response.ok) throw new Error('Admin password reset failed');
setPasswordResetStatus('idle');
setPasswordModal({ admin: null, temporaryPassword: '' });
refreshAdmins();
})
.catch(() => setPasswordResetStatus('error'));
};
const openSshModal = (admin: AdminRow) => {
const account = admin.email.split('@')[0] || 'admin';
const random = Math.random().toString(36).slice(2, 8);
setSshModal({ admin, label: `${account}-${random}-key`, giteaUsername: '', publicKey: '' });
setSshStatus('idle');
setSshError('');
};
const registerAdminSshKey = () => {
if (!sshModal.admin || !sshModal.publicKey.trim()) return;
setSshStatus('saving');
setSshError('');
fetch(`${apiBaseUrl}/admins/${sshModal.admin.id}/ssh-keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: sshModal.label.trim() || undefined,
giteaUsername: sshModal.giteaUsername.trim() || undefined,
publicKey: sshModal.publicKey.trim()
})
})
.then(async (response) => {
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
const message = Array.isArray(errorBody?.message) ? errorBody.message.join(', ') : errorBody?.message;
throw new Error(message || 'SSH 키 등록에 실패했습니다.');
}
return response.json();
})
.then(() => {
setSshStatus('idle');
setSshModal({ admin: null, label: '', giteaUsername: '', publicKey: '' });
refreshAdmins();
})
.catch((error) => {
setSshStatus('error');
setSshError(error instanceof Error ? error.message : 'SSH 키 등록에 실패했습니다.');
});
};
const deleteAdminSshKey = (admin: AdminRow, keyId: string) => {
fetch(`${apiBaseUrl}/admins/${admin.id}/ssh-keys/${keyId}`, { method: 'DELETE' })
.then((response) => {
if (!response.ok) throw new Error('Admin SSH key delete failed');
setSshModal({ admin: null, label: '', giteaUsername: '', publicKey: '' });
refreshAdmins();
})
.catch(() => {
setSshStatus('error');
setSshError('SSH 키 삭제 요청이 실패했습니다.');
});
};
const adminTableRows = adminRows.map((admin, index) => ({
...admin,
no: adminRows.length - index,
state: admin.isActive ? '활성' : '비활성'
}));
type AdminTableRow = (typeof adminTableRows)[number];
const adminColumns: Array<DataTableColumn<AdminTableRow>> = [
{
key: 'no',
header: 'No.',
className: 'w-20 px-5 py-3 font-medium',
cell: (admin) => <span className="font-semibold">{admin.no}</span>
},
{
key: 'name',
header: '관리자',
className: 'w-[260px] px-5 py-3 font-medium',
cell: (admin) => (
<div>
<p className="font-semibold">{admin.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{admin.email}</p>
</div>
)
},
{
key: 'role',
header: '권한',
cell: (admin) => (
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs font-semibold outline-none focus:ring-2 focus:ring-ring"
value={admin.role}
onChange={(event) => updateAdminRole(admin, event.target.value as AdminRow['role'])}
>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="OPERATOR">OPERATOR</option>
<option value="VIEWER">VIEWER</option>
</select>
)
},
{
key: 'session',
header: '최근 세션',
cell: (admin) => {
const session = admin.sessions?.[0];
const sessionState = session ? (session.revokedAt ? '종료' : '활성') : '세션 없음';
return <Badge tone={getTone(sessionState)}>{sessionState}</Badge>;
}
},
{
key: 'sshKeys',
header: 'SSH 키',
cell: (admin) => {
const key = admin.sshKeys?.[0];
if (!key) return <Badge tone="neutral">미등록</Badge>;
return (
<div>
<Badge tone="green">{admin.sshKeys?.length ?? 0}개</Badge>
<p className="mt-1 max-w-44 truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
</div>
);
}
},
{
key: 'lastLoginAt',
header: '마지막 로그인',
cell: (admin) => <span className="text-muted-foreground">{formatDateTime(admin.lastLoginAt)}</span>
},
{
key: 'state',
header: '상태',
cell: (admin) => <Badge tone={getTone(admin.state)}>{admin.state}</Badge>
},
{
key: 'actions',
header: '',
className: 'w-32 px-5 py-3 text-right font-medium',
cell: (admin) => (
<div className="flex justify-end gap-2">
<Button
variant="outline"
className="size-8 px-0"
title="SSH 키 등록"
aria-label="SSH 키 등록"
onClick={() => openSshModal(admin)}
>
<KeyRound className="size-4" />
</Button>
<Button
variant="outline"
className="size-8 px-0"
title={admin.isActive ? '비활성화' : '활성화'}
aria-label={admin.isActive ? '비활성화' : '활성화'}
onClick={() => updateAdminStatus(admin, !admin.isActive)}
>
{admin.isActive ? <Lock className="size-4" /> : <ShieldCheck className="size-4" />}
</Button>
<Button
variant="outline"
className="size-8 px-0"
title="비밀번호 초기화"
aria-label="비밀번호 초기화"
onClick={() => setPasswordModal({ admin, temporaryPassword: '' })}
>
<Lock className="size-4" />
</Button>
</div>
)
}
];
const adminFilters: Array<DataTableFilter<AdminTableRow>> = [
{
key: 'role',
label: '권한',
options: [...new Set(adminTableRows.map((admin) => admin.role))].map((role) => ({ label: role, value: role })),
getValue: (admin) => admin.role
},
{
key: 'state',
label: '상태',
options: [...new Set(adminTableRows.map((admin) => admin.state))].map((state) => ({ label: state, value: state })),
getValue: (admin) => admin.state
}
];
return (
<>
<PageTitle
eyebrow="운영"
title="관리자 관리"
description="스튜디오 어드민 콘솔에 접근할 운영자 계정과 권한을 관리합니다."
actions={
<Button className="h-8 px-3 text-[13px]" onClick={() => setAdminModalOpen(true)}>
<Plus className="size-4" />관리자 등록
</Button>
}
/>
{adminError ? (
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
관리자 데이터를 불러오지 못했습니다.
</div>
) : null}
<DataTableCard
title="목록"
rows={adminTableRows}
columns={adminColumns}
filters={adminFilters}
searchPlaceholder="관리자명, 이메일, 권한 검색"
getSearchText={(admin) => `${admin.name} ${admin.email} ${admin.role} ${admin.state}`}
getRowKey={(admin) => admin.id}
pageSize={6}
minWidth={1040}
loading={adminLoading}
emptyMessage="등록된 관리자가 없습니다."
/>
<LoadingModal open={adminLoadingVisible} />
{adminModalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-lg rounded-lg border border-border bg-card p-5 shadow-xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-sm font-semibold">관리자 등록</h2>
<Button variant="ghost" className="h-8 px-3 text-[13px]" onClick={() => setAdminModalOpen(false)}>
닫기
</Button>
</div>
<div className="space-y-4">
<label className="block text-xs font-medium text-muted-foreground">
이메일
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={newAdmin.email}
onChange={(event) => setNewAdmin((prev) => ({ ...prev, email: event.target.value }))}
/>
</label>
<label className="block text-xs font-medium text-muted-foreground">
이름
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={newAdmin.name}
onChange={(event) => setNewAdmin((prev) => ({ ...prev, name: event.target.value }))}
/>
</label>
<label className="block text-xs font-medium text-muted-foreground">
권한
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={newAdmin.role}
onChange={(event) => setNewAdmin((prev) => ({ ...prev, role: event.target.value as AdminRow['role'] }))}
>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="OPERATOR">OPERATOR</option>
<option value="VIEWER">VIEWER</option>
</select>
</label>
<label className="block text-xs font-medium text-muted-foreground">
임시 비밀번호
<input
type="password"
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={newAdmin.temporaryPassword}
onChange={(event) => setNewAdmin((prev) => ({ ...prev, temporaryPassword: event.target.value }))}
/>
</label>
{adminCreateStatus === 'error' ? (
<p className="text-xs text-rose-300">관리자 등록에 실패했습니다. 이메일 중복과 비밀번호 길이를 확인하세요.</p>
) : null}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setAdminModalOpen(false)}>취소</Button>
<Button
onClick={createAdmin}
disabled={!newAdmin.email.trim() || !newAdmin.name.trim() || newAdmin.temporaryPassword.length < 8 || adminCreateStatus === 'saving'}
>
{adminCreateStatus === 'saving' ? '등록 중' : '등록'}
</Button>
</div>
</div>
</div>
) : null}
{passwordModal.admin ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-5 shadow-xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-sm font-semibold">비밀번호 초기화</h2>
<Button variant="ghost" className="h-8 px-3 text-[13px]" onClick={() => setPasswordModal({ admin: null, temporaryPassword: '' })}>
닫기
</Button>
</div>
<p className="mb-4 text-sm text-muted-foreground">{passwordModal.admin.email}</p>
<label className="block text-xs font-medium text-muted-foreground">
임시 비밀번호
<input
type="password"
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={passwordModal.temporaryPassword}
onChange={(event) => setPasswordModal((prev) => ({ ...prev, temporaryPassword: event.target.value }))}
/>
</label>
{passwordResetStatus === 'error' ? (
<p className="mt-3 text-xs text-rose-300">비밀번호 초기화에 실패했습니다. 8자 이상으로 입력하세요.</p>
) : null}
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setPasswordModal({ admin: null, temporaryPassword: '' })}>취소</Button>
<Button
onClick={resetAdminPassword}
disabled={passwordModal.temporaryPassword.length < 8 || passwordResetStatus === 'saving'}
>
{passwordResetStatus === 'saving' ? '저장 중' : '저장'}
</Button>
</div>
</div>
</div>
) : null}
{sshModal.admin ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-2xl rounded-lg border border-border bg-card p-5 shadow-xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-sm font-semibold">SSH 키 등록</h2>
<Button variant="ghost" className="h-8 px-3 text-[13px]" onClick={() => setSshModal({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>
닫기
</Button>
</div>
<div className="space-y-4">
<label className="block text-xs font-medium text-muted-foreground">
키 이름
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={sshModal.label}
onChange={(event) => setSshModal((prev) => ({ ...prev, label: event.target.value }))}
/>
</label>
<label className="block text-xs font-medium text-muted-foreground">
Gitea 사용자명
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={sshModal.giteaUsername}
onChange={(event) => setSshModal((prev) => ({ ...prev, giteaUsername: event.target.value }))}
/>
</label>
<label className="block text-xs font-medium text-muted-foreground">
공개키
<textarea
className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={sshModal.publicKey}
onChange={(event) => setSshModal((prev) => ({ ...prev, publicKey: event.target.value }))}
/>
</label>
{sshError ? (
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{sshError}</p>
) : null}
{sshModal.admin.sshKeys?.length ? (
<div className="rounded-md border border-border">
{sshModal.admin.sshKeys.map((key) => (
<div key={key.id} className="flex items-center justify-between gap-3 border-b border-border px-3 py-2 last:border-b-0">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
</div>
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshModal.admin && deleteAdminSshKey(sshModal.admin, key.id)}>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
) : null}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setSshModal({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>취소</Button>
<Button onClick={registerAdminSshKey} disabled={!sshModal.publicKey.trim() || sshStatus === 'saving'}>
{sshStatus === 'saving' ? '등록 중' : '등록'}
</Button>
</div>
</div>
</div>
) : null}
</>
);
}
function UserSessionsPage() {
const [sessionRows, setSessionRows] = useState<UserSessionRow[]>([]);
const [sessionLoading, setSessionLoading] = useState(true);
const sessionLoadingVisible = useMinimumLoading(sessionLoading);
const [sessionError, setSessionError] = useState(false);
const refreshSessions = () => {
setSessionLoading(true);
loadUserSessions()
.then((rows) => {
setSessionRows(rows);
setSessionError(false);
setSessionLoading(false);
})
.catch(() => {
setSessionRows([]);
setSessionError(true);
setSessionLoading(false);
});
};
useEffect(() => {
refreshSessions();
}, []);
const lockUser = (userId: string) => {
fetch(`${apiBaseUrl}/users/${userId}/lock`, { method: 'PATCH' })
.then((response) => {
if (!response.ok) throw new Error('User lock failed');
refreshSessions();
})
.catch(() => setSessionError(true));
};
const terminateSession = (sessionId: string | null) => {
if (!sessionId) return;
fetch(`${apiBaseUrl}/users/sessions/${sessionId}`, { method: 'DELETE' })
.then((response) => {
if (!response.ok) throw new Error('Session terminate failed');
refreshSessions();
})
.catch(() => setSessionError(true));
};
type SessionRow = UserSessionRow;
const sessionColumns: Array<DataTableColumn<SessionRow>> = [
{
key: 'no',
header: 'No.',
className: 'w-20 px-5 py-3 font-medium',
cell: (session) => <span className="font-semibold">{session.no}</span>
},
{
key: 'user',
header: '사용자',
className: 'w-[220px] px-5 py-3 font-medium',
cell: (session) => <span className="font-semibold">{session.user}</span>
},
{
key: 'role',
header: '권한',
cell: (session) => <span className="text-muted-foreground">{session.role}</span>
},
{
key: 'session',
header: '세션',
cell: (session) => <span className="font-mono text-xs text-muted-foreground">{session.session}</span>
},
{
key: 'expiresAt',
header: '만료',
cell: (session) => <span className="text-muted-foreground">{formatDateTime(session.expiresAt)}</span>
},
{
key: 'lastSeen',
header: 'Last Seen',
cell: (session) => <span className="text-muted-foreground">{formatDateTime(session.lastSeen)}</span>
},
{
key: 'state',
header: '상태',
cell: (session) => <Badge tone={getTone(session.state)}>{session.state}</Badge>
},
{
key: 'actions',
header: '',
className: 'w-52 px-5 py-3 text-right font-medium',
cell: (session) => (
<div className="flex justify-end gap-2">
<Button variant="outline" className="size-8 px-0" title="잠금" aria-label="잠금" onClick={() => lockUser(session.userId)}>
<Lock className="size-4" />
</Button>
<Button
variant="outline"
className="size-8 px-0"
title="세션 종료"
aria-label="세션 종료"
disabled={!session.sessionId || session.state === '종료' || session.state === '만료'}
onClick={() => terminateSession(session.sessionId)}
>
<LogOut className="size-4" />
</Button>
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</div>
)
}
];
const sessionFilters: Array<DataTableFilter<SessionRow>> = [
{
key: 'state',
label: '상태',
options: [...new Set(sessionRows.map((session) => session.state))].map((state) => ({ label: state, value: state })),
getValue: (session) => session.state
}
];
return (
<>
<PageTitle
eyebrow="운영"
title="사용자/세션"
description="접속 중인 사용자와 세션 상태를 확인하고 필요한 제어 작업을 실행합니다."
/>
{sessionError ? (
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
사용자/세션 데이터를 불러오지 못했습니다.
</div>
) : null}
<DataTableCard
title="목록"
rows={sessionRows}
columns={sessionColumns}
filters={sessionFilters}
searchPlaceholder="사용자, 이메일, 권한, 세션 검색"
getSearchText={(session) => `${session.user} ${session.email} ${session.role} ${session.session} ${session.state}`}
getRowKey={(session) => session.id}
pageSize={6}
minWidth={1180}
loading={sessionLoading}
/>
<LoadingModal open={sessionLoadingVisible} />
</>
);
}
function LicensesPage() {
type AssignmentRow = (typeof userLicenseAssignments)[number];
const assignmentColumns: Array<DataTableColumn<AssignmentRow>> = [
{
key: 'user',
header: '사용자',
className: 'w-[260px] px-5 py-3 font-medium',
cell: (assignment) => (
<div>
<p className="font-semibold">{assignment.user}</p>
<p className="mt-1 text-xs text-muted-foreground">{assignment.name}</p>
</div>
)
},
{
key: 'code',
header: 'Code',
cell: (assignment) => <span className="font-semibold">{assignment.code}</span>
},
{
key: 'licenseName',
header: '라이선스명',
cell: (assignment) => <span className="text-muted-foreground">{assignment.licenseName}</span>
},
{
key: 'startsAt',
header: '시작일',
cell: (assignment) => <span className="text-muted-foreground">{assignment.startsAt}</span>
},
{
key: 'expiresAt',
header: '만료일',
cell: (assignment) => <span className="text-muted-foreground">{assignment.expiresAt}</span>
},
{
key: 'assignedBy',
header: '부여자',
cell: (assignment) => <span className="text-muted-foreground">{assignment.assignedBy}</span>
},
{
key: 'status',
header: '상태',
cell: (assignment) => <Badge tone={getTone(assignment.status)}>{assignment.status}</Badge>
},
{
key: 'actions',
header: '',
className: 'w-28 px-5 py-3 text-right font-medium',
cell: () => (
<div className="text-right">
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</div>
)
}
];
const assignmentFilters: Array<DataTableFilter<AssignmentRow>> = [
{
key: 'code',
label: 'Code',
options: licensePlans.map((license) => ({ label: license.code, value: license.code })),
getValue: (assignment) => assignment.code
},
{
key: 'status',
label: '상태',
options: [...new Set(userLicenseAssignments.map((assignment) => assignment.status))].map((status) => ({ label: status, value: status })),
getValue: (assignment) => assignment.status
}
];
return (
<>
<PageTitle
eyebrow="운영"
title="라이센스"
description="공통 인증 DB의 licenses, user_licenses 기준으로 라이센스 플랜과 사용자 부여 이력을 관리합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />라이센스 부여</Button>}
/>
<div className="grid gap-4 xl:grid-cols-3">
{licensePlans.map((license) => (
<Card key={license.code} className="p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold">{license.code}</p>
<p className="mt-1 text-xs text-muted-foreground">{license.name}</p>
</div>
<Badge tone={getTone(license.status)}>{license.status}</Badge>
</div>
<p className="mt-4 text-sm text-muted-foreground">{license.description}</p>
<div className="mt-5 grid grid-cols-3 gap-3">
<div>
<p className="text-xs text-muted-foreground">부여</p>
<p className="mt-1 text-xl font-semibold">{license.assigned}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">활성</p>
<p className="mt-1 text-xl font-semibold">{license.activeUsers}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">만료임박</p>
<p className="mt-1 text-xl font-semibold">{license.expiringSoon}</p>
</div>
</div>
<p className="mt-4 text-xs text-muted-foreground">기본 기간 {license.defaultDurationDays}일</p>
</Card>
))}
</div>
<div className="mt-4">
<DataTableCard
title="부여 이력"
rows={userLicenseAssignments}
columns={assignmentColumns}
filters={assignmentFilters}
searchPlaceholder="사용자, 라이선스, 부여자 검색"
getSearchText={(assignment) => `${assignment.user} ${assignment.name} ${assignment.code} ${assignment.licenseName} ${assignment.assignedBy} ${assignment.status}`}
getRowKey={(assignment) => `${assignment.user}-${assignment.code}-${assignment.startsAt}`}
pageSize={6}
minWidth={1120}
/>
</div>
</>
);
}
function LauncherCommonSettingsPage() {
const [apiModalOpen, setApiModalOpen] = useState(false);
return (
<>
<PageTitle
eyebrow="런처 운영"
title="공통 설정"
description="런처가 atnmy-stdo-auth, 플랫폼 Gitea, 플랫폼 게이트웨이, 테일스케일 상태 조회와 연결되는 정보를 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setApiModalOpen(true)}>
<ExternalLink className="size-4" />
공통 인증 API 보기
</Button>
<Button className="h-8 px-3 text-[13px]">
<Plus className="size-4" />
설정 추가
</Button>
</>
}
/>
<div className="grid gap-4 md:grid-cols-4">
{[
['설정 항목', `${launcherCommonSettings.length}개`],
['Endpoint', `${launcherCommonSettings.filter((item) => item.type === 'endpoint').length}개`],
['Database', `${launcherCommonSettings.filter((item) => item.type === 'database').length}개`],
['활성 상태', `${launcherCommonSettings.filter((item) => item.status === '활성').length}개`]
].map(([label, value]) => (
<Card key={label} className="p-5">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-3 text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
<div className="mt-4">
<Card className="min-w-0 overflow-hidden">
<CardHeader title="런처 연결 설정" description="세부 운영 설정이 아니라 런처가 공통 인프라에 접근하기 위한 연결 정보입니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[1120px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="w-[280px] px-5 py-3 font-medium">설정</th>
<th className="px-5 py-3 font-medium">Key</th>
<th className="px-5 py-3 font-medium">Type</th>
<th className="px-5 py-3 font-medium">Value</th>
<th className="px-5 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{launcherCommonSettings.map((setting) => (
<tr key={setting.key} className="hover:bg-muted/20">
<td className="px-5 py-4">
<p className="inline-flex items-center gap-2 font-medium">
<span
className={cn(
'size-2 rounded-full',
setting.status === '활성' ? 'bg-emerald-400' : setting.status === '주의' ? 'bg-amber-400' : 'bg-muted-foreground'
)}
/>
{setting.name}
</p>
</td>
<td className="px-5 py-4 font-medium">{setting.key}</td>
<td className="px-5 py-4">{setting.type}</td>
<td className="px-5 py-4 text-muted-foreground">{setting.value}</td>
<td className="px-5 py-4 text-right">
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
{apiModalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-base font-semibold">공통 인증 API</h2>
<p className="mt-1 text-xs text-muted-foreground">atnmy-stdo-auth에서 제공하는 런처 및 앱 연동 엔드포인트입니다.</p>
</div>
<Button variant="ghost" className="h-8 px-3 text-[13px]" onClick={() => setApiModalOpen(false)}>
닫기
</Button>
</div>
<div className="max-h-[70vh] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">그룹</th>
<th className="px-5 py-3 font-medium">Method</th>
<th className="px-5 py-3 font-medium">Path</th>
<th className="px-5 py-3 font-medium">용도</th>
<th className="px-5 py-3 font-medium">권한</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{authApiEndpoints.map((endpoint) => (
<tr key={`${endpoint.method}-${endpoint.path}`} className="hover:bg-muted/20">
<td className="px-5 py-4">{endpoint.group}</td>
<td className="px-5 py-4 font-semibold">{endpoint.method}</td>
<td className="px-5 py-4 font-medium">{endpoint.path}</td>
<td className="px-5 py-4 text-muted-foreground">{endpoint.purpose}</td>
<td className="px-5 py-4">
<Badge tone={endpoint.auth === '없음' ? 'neutral' : endpoint.auth === 'ADMIN' ? 'amber' : 'blue'}>
{endpoint.auth}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : null}
</>
);
}
function AppSourcesPage() {
const [giteaRepos, setGiteaRepos] = useState<GiteaRepo[]>([]);
const [apiApps, setApiApps] = useState<ApiApp[]>([]);
const [apiSources, setApiSources] = useState<ApiAppComponent[]>([]);
const [sourcesLoading, setSourcesLoading] = useState(true);
const [giteaStatus, setGiteaStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const sourcesLoadingVisible = useMinimumLoading(giteaStatus === 'loading' || sourcesLoading);
const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false);
const [isComponentOpen, setIsComponentOpen] = useState(false);
const [componentAppId, setComponentAppId] = useState('');
const [componentName, setComponentName] = useState('');
const [componentType, setComponentType] = useState('CONTAINER');
const [componentRepo, setComponentRepo] = useState('');
const [componentBranch, setComponentBranch] = useState('develop');
const [componentSaveStatus, setComponentSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [repoName, setRepoName] = useState('');
const [repoDescription, setRepoDescription] = useState('');
const [repoPrivate, setRepoPrivate] = useState(true);
const [createDevelopBranch, setCreateDevelopBranch] = useState(true);
const [createReleaseBranch, setCreateReleaseBranch] = useState(true);
const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
useEffect(() => {
let mounted = true;
loadApps()
.then((apps) => {
if (!mounted) return;
setApiApps(apps);
setComponentAppId((current) => current || apps[0]?.id || '');
})
.catch(() => setApiApps([]));
setSourcesLoading(true);
loadAppSources()
.then((sources) => {
if (!mounted) return;
setApiSources(sources);
setSourcesLoading(false);
})
.catch(() => {
if (!mounted) return;
setApiSources([]);
setSourcesLoading(false);
});
loadPlatformRepos()
.then((response) => {
if (!mounted) return null;
if (!response.ok) {
throw new Error('Gitea repositories request failed');
}
return response.json() as Promise<{ repos: GiteaRepo[] }>;
})
.then((data) => {
if (!mounted || !data) return;
setGiteaRepos(data.repos ?? []);
setGiteaStatus('ready');
})
.catch(() => {
if (!mounted) return;
setGiteaStatus('error');
});
return () => {
mounted = false;
};
}, []);
const refreshRepos = () => {
setGiteaStatus('loading');
loadPlatformRepos()
.then((response) => {
if (!response.ok) {
throw new Error('Gitea repositories request failed');
}
return response.json() as Promise<{ repos: GiteaRepo[] }>;
})
.then((data) => {
setGiteaRepos(data.repos ?? []);
setGiteaStatus('ready');
})
.catch(() => setGiteaStatus('error'));
};
const createRepo = () => {
if (!repoName.trim()) return;
setRepoCreateStatus('saving');
fetch(`${apiBaseUrl}/gitea/orgs/platform/repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: repoName.trim(),
description: repoDescription.trim() || undefined,
private: repoPrivate,
autoInit: true,
createDevelopBranch,
createReleaseBranch
})
})
.then((response) => {
if (!response.ok) {
throw new Error('Gitea repository create failed');
}
return response.json();
})
.then(() => {
setRepoName('');
setRepoDescription('');
setRepoPrivate(true);
setCreateDevelopBranch(true);
setCreateReleaseBranch(true);
setRepoCreateStatus('idle');
setIsCreateRepoOpen(false);
refreshRepos();
})
.catch(() => setRepoCreateStatus('error'));
};
const refreshSources = () => {
loadApps().then(setApiApps).catch(() => setApiApps([]));
setSourcesLoading(true);
loadAppSources()
.then((sources) => {
setApiSources(sources);
setSourcesLoading(false);
})
.catch(() => {
setApiSources([]);
setSourcesLoading(false);
});
};
const createComponent = () => {
if (!componentAppId || !componentName.trim()) return;
setComponentSaveStatus('saving');
fetch(`${apiBaseUrl}/apps/${componentAppId}/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: componentName.trim(),
type: componentType,
repository: componentRepo || undefined,
branch: componentBranch || undefined,
buildTarget: getBuildTargetForType(componentType)
})
})
.then((response) => {
if (!response.ok) throw new Error('Component create failed');
return response.json();
})
.then(() => {
setComponentName('');
setComponentType('CONTAINER');
setComponentRepo('');
setComponentBranch('develop');
setComponentSaveStatus('idle');
setIsComponentOpen(false);
refreshSources();
})
.catch(() => setComponentSaveStatus('error'));
};
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="소스 관리"
description="플랫폼 Gitea에 등록된 앱별 저장소, 브랜치, 커밋, 빌드 대상을 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setIsComponentOpen(true)}>
<Plus className="size-4" />
구성요소 추가
</Button>
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateRepoOpen(true)}>
<Plus className="size-4" />
저장소 생성
</Button>
</>
}
/>
<Card className="mb-4 min-w-0 overflow-hidden">
<CardHeader
title="Gitea 저장소"
description="platform 조직에 등록된 앱/런처 소스를 앱 구성요소의 Repository로 연결합니다."
/>
<div className="max-h-[300px] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">Repository</th>
<th className="px-5 py-3 font-medium">Owner</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">Clone URL</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{giteaStatus === 'error' ? (
<tr>
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>백엔드 연결 후 Gitea 저장소가 표시됩니다.</td>
</tr>
) : giteaStatus !== 'loading' && giteaRepos.length === 0 ? (
<tr>
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>platform 조직에 등록된 저장소가 없습니다.</td>
</tr>
) : (
giteaRepos.map((repo) => (
<tr key={repo.id} className="hover:bg-muted/20">
<td className="px-5 py-4">
<div className="flex items-center gap-2">
<span className="font-semibold">{repo.fullName}</span>
<a href={repo.htmlUrl} target="_blank" rel="noreferrer" className="text-muted-foreground hover:text-foreground">
<ExternalLink className="size-3.5" />
</a>
</div>
{repo.description ? <p className="mt-1 text-xs text-muted-foreground">{repo.description}</p> : null}
</td>
<td className="px-5 py-4">{repo.owner}</td>
<td className="px-5 py-4">{repo.defaultBranch}</td>
<td className="px-5 py-4 text-muted-foreground">{repo.language || '-'}</td>
<td className="px-5 py-4 text-muted-foreground">{repo.cloneUrl}</td>
<td className="px-5 py-4">
<Badge tone={repo.private ? 'amber' : 'blue'}>{repo.private ? 'Private' : 'Public'}</Badge>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="Gitea 소스 연결" description="앱 구성요소별 저장소와 빌드 대상을 확인합니다." />
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[1120px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">앱</th>
<th className="px-5 py-3 font-medium">구성요소</th>
<th className="px-5 py-3 font-medium">Repository</th>
<th className="px-5 py-3 font-medium">기본 브랜치</th>
<th className="px-5 py-3 font-medium">Commit</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">Clone URL</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{!sourcesLoading && apiSources.length === 0 ? (
<tr>
<td colSpan={9} className="px-5 py-6 text-center text-muted-foreground">등록된 앱 구성요소가 없습니다.</td>
</tr>
) : (
!sourcesLoading && apiSources.map((source) => (
<tr key={source.id} className="hover:bg-muted/20">
<td className="px-5 py-4 font-semibold">{source.app?.name ?? '-'}</td>
<td className="px-5 py-4">{source.name}</td>
<td className="px-5 py-4 text-muted-foreground">{source.repository || '미연결'}</td>
<td className="px-5 py-4">{source.branch || '-'}</td>
<td className="px-5 py-4 font-medium">-</td>
<td className="px-5 py-4 text-muted-foreground">{source.type}</td>
<td className="px-5 py-4 text-muted-foreground">{source.buildTarget || '-'}</td>
<td className="px-5 py-4 text-muted-foreground">
{source.repository ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-mono text-xs">{getHttpCloneUrl(source.repository)}</p>
<button
type="button"
title="HTTP clone URL 복사"
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => copyToClipboard(getHttpCloneUrl(source.repository!))}
>
<Copy className="size-3.5" />
</button>
</div>
<div className="flex items-center gap-2">
<p className="font-mono text-xs">{getSshCloneUrl(source.repository)}</p>
<button
type="button"
title="SSH clone URL 복사"
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => copyToClipboard(getSshCloneUrl(source.repository!))}
>
<Copy className="size-3.5" />
</button>
</div>
</div>
) : '-'}
</td>
<td className="px-5 py-4"><Badge tone={source.repository ? 'green' : 'neutral'}>{source.repository ? '연결됨' : '미연결'}</Badge></td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
{isComponentOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold">앱 구성요소 추가</h2>
</div>
<div className="space-y-4 px-5 py-5">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">앱 그룹</span>
<select value={componentAppId} onChange={(event) => setComponentAppId(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary">
{apiApps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
{apiApps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">구성요소명</span>
<input value={componentName} onChange={(event) => setComponentName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">유형</span>
<select value={componentType} onChange={(event) => setComponentType(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary">
<option value="ELECTRON">ELECTRON</option>
<option value="CONTAINER">CONTAINER</option>
<option value="STATIC_WEB">STATIC_WEB</option>
<option value="DATABASE">DATABASE</option>
<option value="MIGRATION">MIGRATION</option>
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Repository</span>
<select value={componentRepo} onChange={(event) => setComponentRepo(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary">
<option value="">미연결</option>
{giteaRepos.map((repo) => <option key={repo.id} value={repo.fullName}>{repo.fullName}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">기본 브랜치</span>
<select value={componentBranch} onChange={(event) => setComponentBranch(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary">
<option value="develop">develop</option>
<option value="release">release</option>
<option value="main">main</option>
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">빌드 대상</span>
<input
value={getBuildTargetForType(componentType)}
readOnly
className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 text-sm text-muted-foreground outline-none"
/>
</label>
{componentSaveStatus === 'error' ? <p className="text-xs text-rose-300">구성요소 저장에 실패했습니다.</p> : null}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={() => setIsComponentOpen(false)}>취소</Button>
<Button onClick={createComponent} disabled={!componentAppId || !componentName.trim() || componentSaveStatus === 'saving'}>{componentSaveStatus === 'saving' ? '저장 중' : '저장'}</Button>
</div>
</div>
</div>
) : null}
{isCreateRepoOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold">platform 저장소 생성</h2>
</div>
<div className="space-y-4 px-5 py-5">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
<input
value={repoName}
onChange={(event) => setRepoName(event.target.value)}
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">설명</span>
<textarea
value={repoDescription}
onChange={(event) => setRepoDescription(event.target.value)}
className="mt-2 min-h-20 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={repoPrivate}
onChange={(event) => setRepoPrivate(event.target.checked)}
className="size-4 accent-primary"
/>
Private 저장소로 생성
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={createDevelopBranch}
onChange={(event) => setCreateDevelopBranch(event.target.checked)}
className="size-4 accent-primary"
/>
develop 브랜치 자동 생성
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={createReleaseBranch}
onChange={(event) => setCreateReleaseBranch(event.target.checked)}
className="size-4 accent-primary"
/>
release 브랜치 자동 생성
</label>
{repoCreateStatus === 'error' ? (
<p className="text-xs text-rose-300">저장소 생성에 실패했습니다. GITEA_ADMIN_TOKEN 권한과 중복 저장소명을 확인하세요.</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={() => setIsCreateRepoOpen(false)}>취소</Button>
<Button onClick={createRepo} disabled={!repoName.trim() || repoCreateStatus === 'saving'}>
{repoCreateStatus === 'saving' ? '생성 중' : '생성'}
</Button>
</div>
</div>
</div>
) : null}
<LoadingModal open={sourcesLoadingVisible} />
</>
);
}
function loadPlatformRepos() {
return fetch(`${apiBaseUrl}/gitea/repos?owner=platform&limit=8`);
}
function loadLauncherVersions() {
return fetch(`${apiBaseUrl}/launcher/versions`)
.then((response) => {
if (!response.ok) {
throw new Error('Launcher versions request failed');
}
return response.json() as Promise<ApiLauncherVersion[]>;
});
}
function loadApps() {
return fetch(`${apiBaseUrl}/apps`)
.then((response) => {
if (!response.ok) {
throw new Error('Apps request failed');
}
return response.json() as Promise<ApiApp[]>;
});
}
function loadAppSources() {
return fetch(`${apiBaseUrl}/apps/sources`)
.then((response) => {
if (!response.ok) {
throw new Error('App sources request failed');
}
return response.json() as Promise<ApiAppComponent[]>;
});
}
function loadUserSessions() {
return fetch(`${apiBaseUrl}/users/sessions`)
.then((response) => {
if (!response.ok) {
throw new Error('User sessions request failed');
}
return response.json() as Promise<{ sessions: UserSessionRow[] }>;
})
.then((data) => data.sessions ?? []);
}
function loadAdmins() {
return fetch(`${apiBaseUrl}/admins`)
.then((response) => {
if (!response.ok) {
throw new Error('Admins request failed');
}
return response.json() as Promise<AdminRow[] | { admins: AdminRow[] }>;
})
.then((data) => (Array.isArray(data) ? data : data.admins ?? []));
}
function getBuildTargetForType(type: string) {
const targets: Record<string, string> = {
ELECTRON: 'AppImage',
CONTAINER: 'container-image',
STATIC_WEB: 'web-image',
DATABASE: 'database',
MIGRATION: 'prisma/migrations',
};
return targets[type] ?? 'container-image';
}
function formatDateTime(value: string | null) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function getHttpCloneUrl(repository: string) {
return `${giteaBaseUrl.replace(/\/$/, '')}/${repository}.git`;
}
function getSshCloneUrl(repository: string) {
return `${giteaSshBaseUrl.replace(/\/$/, '')}/${repository}.git`;
}
function copyToClipboard(value: string) {
void navigator.clipboard?.writeText(value);
}
function getBuildArtifact(component: ApiAppComponent, branch: string, commitSha: string) {
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
if (component.type === 'ELECTRON') {
return `${component.name}-${suffix}.AppImage`;
}
if (component.type === 'DATABASE' || component.type === 'MIGRATION') {
return `${component.name}:${branch}:migration-check`;
}
return `registry.local/${component.name}:${suffix}`;
}
function getBuildLogLines(job: {
component: string;
repository: string;
branch: string;
commit: string;
target: string;
artifact: string;
status: string;
}) {
return [
`[00:00] build job initialized: ${job.component}`,
`[00:01] repository: ${job.repository}`,
`[00:02] checkout branch: ${job.branch}`,
`[00:03] checkout commit: ${job.commit}`,
`[00:08] install dependencies`,
`[00:25] run build target: ${job.target}`,
`[01:10] package artifact: ${job.artifact}`,
`[01:20] status: ${job.status}`
];
}
function AppBuildsPage() {
type BuildJobRow = {
id: string;
no: number;
app: string;
component: string;
repository: string;
branch: string;
commit: string;
target: string;
artifact: string;
requestedBy: string;
startedAt: string;
endedAt: string;
duration: string;
status: string;
};
const [apiApps, setApiApps] = useState<ApiApp[]>([]);
const [apiSources, setApiSources] = useState<ApiAppComponent[]>([]);
const [buildsLoading, setBuildsLoading] = useState(true);
const buildsLoadingVisible = useMinimumLoading(buildsLoading);
const [selectedAppId, setSelectedAppId] = useState('');
const [selectedComponentId, setSelectedComponentId] = useState('');
const [selectedBranch, setSelectedBranch] = useState('release');
const [commitSha, setCommitSha] = useState('');
const [buildRows, setBuildRows] = useState<BuildJobRow[]>([]);
const [logJob, setLogJob] = useState<BuildJobRow | null>(null);
useEffect(() => {
let mounted = true;
setBuildsLoading(true);
Promise.all([loadApps(), loadAppSources()])
.then(([apps, sources]) => {
if (!mounted) return;
setApiApps(apps);
setApiSources(sources);
setSelectedAppId((current) => current || apps[0]?.id || '');
const firstSource = sources.find((source) => source.appId === (apps[0]?.id ?? '')) ?? sources[0];
setSelectedComponentId((current) => current || firstSource?.id || '');
setSelectedBranch(firstSource?.branch || 'release');
setBuildsLoading(false);
})
.catch(() => {
if (!mounted) return;
setApiApps([]);
setApiSources([]);
setBuildsLoading(false);
});
return () => {
mounted = false;
};
}, []);
const selectedApp = apiApps.find((app) => app.id === selectedAppId);
const componentOptions = apiSources.filter((source) => source.appId === selectedAppId);
const selectedComponent = apiSources.find((source) => source.id === selectedComponentId) ?? componentOptions[0];
const selectedTarget = selectedComponent?.buildTarget || (selectedComponent ? getBuildTargetForType(selectedComponent.type) : '-');
const predictedArtifact = selectedComponent
? getBuildArtifact(selectedComponent, selectedBranch, commitSha)
: '-';
useEffect(() => {
if (!selectedAppId) return;
const firstComponent = apiSources.find((source) => source.appId === selectedAppId);
setSelectedComponentId(firstComponent?.id || '');
setSelectedBranch(firstComponent?.branch || 'release');
}, [apiSources, selectedAppId]);
const runBuild = () => {
if (!selectedApp || !selectedComponent) return;
const nextRow: BuildJobRow = {
id: `local-${Date.now()}`,
no: buildRows.length + 1,
app: selectedApp.name,
component: selectedComponent.name,
repository: selectedComponent.repository || '-',
branch: selectedBranch,
commit: commitSha || '-',
target: selectedTarget,
artifact: predictedArtifact,
requestedBy: 'admin',
startedAt: '방금',
endedAt: '-',
duration: '대기 중',
status: selectedComponent.repository ? '대기' : '검토필요'
};
setBuildRows((prev) => [nextRow, ...prev.map((row, index) => ({ ...row, no: prev.length - index }))]);
setLogJob(nextRow);
};
const retryBuild = (job: BuildJobRow) => {
setBuildRows((prev) => [
{
...job,
id: `retry-${Date.now()}`,
no: prev.length + 1,
startedAt: '방금',
endedAt: '-',
duration: '대기 중',
status: '대기'
},
...prev.map((row, index) => ({ ...row, no: prev.length - index }))
]);
};
const buildColumns: Array<DataTableColumn<BuildJobRow>> = [
{
key: 'no',
header: 'No.',
className: 'w-20 px-5 py-3 font-medium',
cell: (job) => <span className="font-semibold">{job.no}</span>
},
{
key: 'app',
header: '앱',
className: 'w-[180px] px-5 py-3 font-medium',
cell: (job) => <span className="font-semibold">{job.app}</span>
},
{
key: 'component',
header: '구성요소',
cell: (job) => <span>{job.component}</span>
},
{
key: 'repository',
header: 'Repository',
cell: (job) => <span className="text-muted-foreground">{job.repository}</span>
},
{
key: 'branch',
header: 'Branch',
cell: (job) => <span>{job.branch}</span>
},
{
key: 'commit',
header: 'Commit',
cell: (job) => <span className="font-mono text-xs text-muted-foreground">{job.commit}</span>
},
{
key: 'target',
header: '빌드 대상',
cell: (job) => <span className="text-muted-foreground">{job.target}</span>
},
{
key: 'artifact',
header: '결과물',
cell: (job) => <span className="text-muted-foreground">{job.artifact}</span>
},
{
key: 'requestedBy',
header: '요청자',
cell: (job) => <span className="text-muted-foreground">{job.requestedBy}</span>
},
{
key: 'startedAt',
header: '시작',
cell: (job) => <span className="text-muted-foreground">{job.startedAt}</span>
},
{
key: 'endedAt',
header: '종료',
cell: (job) => <span className="text-muted-foreground">{job.endedAt}</span>
},
{
key: 'status',
header: '상태',
cell: (job) => <Badge tone={getTone(job.status)}>{job.status}</Badge>
},
{
key: 'actions',
header: '',
className: 'w-28 px-5 py-3 text-right font-medium',
cell: (job) => (
<div className="flex justify-end gap-2">
<Button variant="outline" className="size-8 px-0" title="로그 보기" aria-label="로그 보기" onClick={() => setLogJob(job)}>
<MoreHorizontal className="size-4" />
</Button>
<Button variant="outline" className="size-8 px-0" title="재실행" aria-label="재실행" onClick={() => retryBuild(job)}>
<RotateCcw className="size-4" />
</Button>
</div>
)
}
];
const buildFilters: Array<DataTableFilter<BuildJobRow>> = [
{
key: 'app',
label: '앱',
options: [...new Set(buildRows.map((job) => job.app))].map((app) => ({ label: app, value: app })),
getValue: (job) => job.app
},
{
key: 'status',
label: '상태',
options: [...new Set(buildRows.map((job) => job.status))].map((status) => ({ label: status, value: status })),
getValue: (job) => job.status
}
];
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="빌드 관리"
description="Gitea 소스에서 컨테이너 이미지와 런처 다운로드용 클라이언트 산출물을 만드는 작업을 관리합니다."
actions={
<Button className="h-8 px-3 text-[13px]" onClick={runBuild} disabled={!selectedComponent || buildsLoading}>
<Play className="size-4" />빌드 실행
</Button>
}
/>
<Card className="mb-4">
<CardHeader title="빌드 요청" description="앱 그룹, 구성요소, 브랜치, 커밋 기준으로 빌드 작업을 생성합니다." />
<div className="grid gap-4 p-5 xl:grid-cols-[1.2fr_1fr]">
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">앱 그룹</span>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={selectedAppId}
onChange={(event) => setSelectedAppId(event.target.value)}
>
{apiApps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
{apiApps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={selectedComponent?.id ?? ''}
onChange={(event) => {
const next = apiSources.find((source) => source.id === event.target.value);
setSelectedComponentId(event.target.value);
setSelectedBranch(next?.branch || 'release');
}}
>
{componentOptions.length === 0 ? <option value="">구성요소 없음</option> : null}
{componentOptions.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Branch</span>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={selectedBranch}
onChange={(event) => setSelectedBranch(event.target.value)}
>
<option value="develop">develop</option>
<option value="release">release</option>
<option value="main">main</option>
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={commitSha}
onChange={(event) => setCommitSha(event.target.value)}
/>
</label>
</div>
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-4 text-sm">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Repository</span>
<span className="truncate font-semibold">{selectedComponent?.repository || '미연결'}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">빌드 대상</span>
<span className="truncate font-semibold">{selectedTarget}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">예상 결과물</span>
<span className="truncate font-semibold">{predictedArtifact}</span>
</div>
<Button className="mt-1 h-8 px-3 text-[13px]" onClick={runBuild} disabled={!selectedComponent || buildsLoading}>
<Play className="size-4" />빌드 실행
</Button>
</div>
</div>
</Card>
<DataTableCard
title="빌드 작업 목록"
rows={buildRows}
columns={buildColumns}
filters={buildFilters}
searchPlaceholder="앱, 구성요소, 저장소, 결과물 검색"
getSearchText={(job) => `${job.app} ${job.component} ${job.repository} ${job.branch} ${job.commit} ${job.target} ${job.artifact} ${job.status}`}
getRowKey={(job) => job.id}
pageSize={6}
minWidth={1480}
/>
{logJob ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
<div className="w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-xl">
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold">빌드 로그</h2>
<p className="mt-1 text-xs text-muted-foreground">{logJob.app} · {logJob.component} · {logJob.branch}</p>
</div>
<Button variant="ghost" onClick={() => setLogJob(null)}>닫기</Button>
</div>
<div className="max-h-[70vh] overflow-auto bg-background p-5 font-mono text-xs leading-6 text-muted-foreground">
{getBuildLogLines(logJob).map((line) => (
<p key={line}>{line}</p>
))}
</div>
</div>
</div>
) : null}
<LoadingModal open={buildsLoadingVisible} />
</>
);
}
function AppReleasesPage() {
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="릴리즈 관리"
description="브랜치, 커밋, 빌드 산출물을 묶어 환경별로 배포 가능한 릴리즈를 생성합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />릴리즈 생성</Button>}
/>
<div className="grid gap-4 xl:grid-cols-3">
{appReleasePipelines.map((release) => (
<Card key={`${release.app}-${release.version}`} className="p-5">
<div className="flex items-center justify-between gap-4">
<p className="truncate text-sm font-semibold">{release.app}</p>
<Badge tone={getTone(release.status)}>{release.status}</Badge>
</div>
<p className="mt-3 text-2xl font-semibold">{release.version}</p>
<p className="mt-2 text-xs text-muted-foreground">{`${release.sourceBranch} -> ${release.releaseBranch} · ${release.commit}`}</p>
<p className="mt-3 text-sm text-muted-foreground">{release.artifacts}</p>
<p className="mt-3 text-xs text-muted-foreground">{release.createdAt}</p>
</Card>
))}
</div>
</>
);
}
function AppDeploymentsPage() {
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="배포 관리"
description="생성된 릴리즈를 개발, 검수, 운영 환경에 배포하고 재배포와 롤백을 관리합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />배포 실행</Button>}
/>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="배포 작업" description="환경별 배포 상태와 실행자를 확인합니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">앱</th>
<th className="px-5 py-3 font-medium">환경</th>
<th className="px-5 py-3 font-medium">릴리즈</th>
<th className="px-5 py-3 font-medium">전략</th>
<th className="px-5 py-3 font-medium">대상</th>
<th className="px-5 py-3 font-medium">요청자</th>
<th className="px-5 py-3 font-medium">시각</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{appDeploymentJobs.map((job) => (
<tr key={`${job.app}-${job.environment}-${job.time}`} className="hover:bg-muted/20">
<td className="px-5 py-4 font-semibold">{job.app}</td>
<td className="px-5 py-4">{job.environment}</td>
<td className="px-5 py-4">{job.release}</td>
<td className="px-5 py-4 text-muted-foreground">{job.strategy}</td>
<td className="px-5 py-4 text-muted-foreground">{job.target}</td>
<td className="px-5 py-4">{job.requestedBy}</td>
<td className="px-5 py-4 text-muted-foreground">{job.time}</td>
<td className="px-5 py-4"><Badge tone={getTone(job.status)}>{job.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function AppMigrationsPage() {
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="마이그레이션"
description="앱 릴리즈에 포함된 DB 마이그레이션 경로와 실행 이력을 관리합니다."
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />마이그레이션 실행</Button>}
/>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="마이그레이션 작업" description="브랜치별 DB 변경 적용 상태입니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">앱</th>
<th className="px-5 py-3 font-medium">구성요소</th>
<th className="px-5 py-3 font-medium">Branch</th>
<th className="px-5 py-3 font-medium">Path</th>
<th className="px-5 py-3 font-medium">Version</th>
<th className="px-5 py-3 font-medium">시각</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{appMigrationJobs.map((job) => (
<tr key={`${job.app}-${job.version}`} className="hover:bg-muted/20">
<td className="px-5 py-4 font-semibold">{job.app}</td>
<td className="px-5 py-4">{job.component}</td>
<td className="px-5 py-4">{job.branch}</td>
<td className="px-5 py-4 text-muted-foreground">{job.path}</td>
<td className="px-5 py-4 text-muted-foreground">{job.version}</td>
<td className="px-5 py-4 text-muted-foreground">{job.time}</td>
<td className="px-5 py-4"><Badge tone={getTone(job.status)}>{job.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function RuntimeConfigPage() {
return (
<>
<PageTitle eyebrow="플랫폼 런타임" title="런타임 구성" description="클러스터에서 실행 중인 앱 워크로드와 노드 배치, 재시작 상태를 확인합니다." />
<Card className="min-w-0 overflow-hidden">
<CardHeader title="워크로드 현황" description="앱 그룹별 실행 리소스와 Pod 상태입니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[1180px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">앱</th>
<th className="px-5 py-3 font-medium">Namespace</th>
<th className="px-5 py-3 font-medium">워크로드</th>
<th className="px-5 py-3 font-medium">Pod</th>
<th className="px-5 py-3 font-medium">Image</th>
<th className="px-5 py-3 font-medium">Replicas</th>
<th className="px-5 py-3 font-medium">Node</th>
<th className="px-5 py-3 font-medium">Restarts</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{k3sWorkloads.map((item) => (
<tr key={`${item.namespace}-${item.pod}`} className="hover:bg-muted/20">
<td className="px-5 py-4 font-medium">{item.app}</td>
<td className="px-5 py-4 text-muted-foreground">{item.namespace}</td>
<td className="px-5 py-4">{item.workload}</td>
<td className="px-5 py-4 text-muted-foreground">{item.pod}</td>
<td className="px-5 py-4 text-muted-foreground">{item.image}</td>
<td className="px-5 py-4">{item.replicas}</td>
<td className="px-5 py-4 text-muted-foreground">{item.node}</td>
<td className="px-5 py-4">{item.restarts}</td>
<td className="px-5 py-4"><Badge tone={getTone(item.status)}>{item.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function RuntimeLogsPage() {
return (
<>
<PageTitle eyebrow="플랫폼 런타임" title="로그/이벤트" description="실행 중인 워크로드의 로그, 이벤트, 최근 제어 명령 결과를 확인합니다." />
<Card>
<CardHeader title="최근 작업" description="로그 조회, 이벤트 조회, 재시작 같은 런타임 제어 요청입니다." />
<div className="divide-y divide-border">
{runtimeCommands.map((item) => (
<div key={`${item.target}-${item.command}`} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{item.command}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.target} · {item.requestedBy}</p>
</div>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
))}
</div>
</Card>
</>
);
}
function RuntimeDeviceControlPage() {
return (
<>
<PageTitle eyebrow="플랫폼 런타임" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="장비 목록"
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
action={
<div className="flex gap-2">
<Button variant="outline" className="h-8 px-3 text-[13px]">
<SlidersHorizontal className="size-4" />
상태 필터
</Button>
<Button className="h-8 px-3 text-[13px]">
<Activity className="size-4" />
상태 갱신
</Button>
</div>
}
/>
<div className="border-b border-border px-5 pb-4">
<div className="relative max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="장비명, 계정, 워크스페이스 검색"
/>
</div>
</div>
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[1040px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">장비</th>
<th className="px-5 py-3 font-medium">계정</th>
<th className="px-5 py-3 font-medium">Workspace</th>
<th className="px-5 py-3 font-medium">SSH 키</th>
<th className="px-5 py-3 font-medium">Last Seen</th>
<th className="px-5 py-3 font-medium">상태</th>
<th className="px-5 py-3 text-right font-medium">명령</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{agentDevices.map((device) => (
<tr key={device.name} className="hover:bg-muted/20">
<td className="px-5 py-4 font-medium">{device.name}</td>
<td className="px-5 py-4 text-muted-foreground">{device.account}</td>
<td className="px-5 py-4 text-muted-foreground">{device.workspace}</td>
<td className="px-5 py-4">{device.sshKey}</td>
<td className="px-5 py-4 text-muted-foreground">{device.lastSeen}</td>
<td className="px-5 py-4">
<Badge tone={getTone(device.status)}>{device.status}</Badge>
</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
<Button variant="ghost" className="size-8 px-0">
<MoreHorizontal className="size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function ContainerConfigPage() {
const resources = containerBlueprints.flatMap((blueprint) => blueprint.resources.map((resource) => ({ app: blueprint.app, ...resource })));
return (
<>
<PageTitle eyebrow="플랫폼 런타임" title="ConfigMap/Secret" description="워크로드별 환경변수와 시크릿 연결 수를 관리합니다." />
<div className="grid gap-4 xl:grid-cols-3">
{resources.map((resource) => (
<Card key={`${resource.app}-${resource.name}`} className="p-5">
<p className="text-sm font-semibold">{resource.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{resource.app}</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">Env</p>
<p className="mt-2 text-xl font-semibold">{resource.env}</p>
</div>
<div className="rounded-md bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">Secrets</p>
<p className="mt-2 text-xl font-semibold">{resource.secrets}</p>
</div>
</div>
</Card>
))}
</div>
</>
);
}
function ContainerStoragePage() {
const resources = containerBlueprints.flatMap((blueprint) => blueprint.resources.map((resource) => ({ app: blueprint.app, namespace: blueprint.namespace, ...resource })));
return (
<>
<PageTitle eyebrow="플랫폼 런타임" title="스토리지" description="StatefulSet, PVC, 볼륨 마운트가 필요한 리소스를 관리합니다." />
<Card>
<CardHeader title="스토리지 리소스" description="PVC가 필요한 리소스만 표시합니다." />
<div className="divide-y divide-border">
{resources
.filter((resource) => resource.storage !== '-')
.map((resource) => (
<div key={`${resource.app}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{resource.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{resource.app} · {resource.namespace}</p>
</div>
<Badge tone="blue">{resource.storage}</Badge>
</div>
))}
</div>
</Card>
</>
);
}
function TailscaleStatusPage() {
const activeNodes = tailscaleNodes.filter((node) => node.status === '활성').length;
const routeNodes = tailscaleNodes.filter((node) => node.routes !== '-').length;
return (
<>
<PageTitle
eyebrow="테일스케일"
title="노드 현황"
description="플랫폼 게이트웨이가 수집한 tailnet 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다."
actions={
<Button className="h-8 px-3 text-[13px]">
<Activity className="size-4" />
상태 갱신
</Button>
}
/>
<div className="grid gap-4 md:grid-cols-3">
{[
['등록 노드', `${tailscaleNodes.length}대`, '게이트웨이 수집 기준'],
['온라인', `${activeNodes}대`, '최근 접속 정상'],
['Subnet Router', `${routeNodes}대`, '클러스터 CIDR 광고']
].map(([label, value, hint]) => (
<Card key={label} className="p-5">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-3 text-2xl font-semibold">{value}</p>
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
</Card>
))}
</div>
<div className="mt-4">
<Card className="min-w-0 overflow-hidden">
<CardHeader title="노드 현황" description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">노드</th>
<th className="px-5 py-3 font-medium">IP</th>
<th className="px-5 py-3 font-medium">역할</th>
<th className="px-5 py-3 font-medium">사용자</th>
<th className="px-5 py-3 font-medium">Routes</th>
<th className="px-5 py-3 font-medium">Last Seen</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{tailscaleNodes.map((node) => (
<tr key={node.name} className="hover:bg-muted/20">
<td className="px-5 py-4 font-medium">{node.name}</td>
<td className="px-5 py-4">{node.ip}</td>
<td className="px-5 py-4 text-muted-foreground">{node.role}</td>
<td className="px-5 py-4 text-muted-foreground">{node.user}</td>
<td className="px-5 py-4 text-muted-foreground">{node.routes}</td>
<td className="px-5 py-4 text-muted-foreground">{node.lastSeen}</td>
<td className="px-5 py-4">
<Badge tone={getTone(node.status)}>{node.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
</>
);
}
function TailscaleRoutesPage() {
const routeNodes = tailscaleNodes.filter((node) => node.routes !== '-');
return (
<>
<PageTitle eyebrow="테일스케일" title="라우트" description="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." />
<Card>
<CardHeader title="라우트 광고" description="승인된 subnet route와 노드 상태입니다." />
<div className="divide-y divide-border">
{routeNodes.map((node) => (
<div key={node.name} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{node.routes}</p>
<p className="mt-1 text-xs text-muted-foreground">{node.name} · {node.ip}</p>
</div>
<Badge tone={getTone(node.status)}>{node.status}</Badge>
</div>
))}
</div>
</Card>
</>
);
}
function TailscaleAclPage() {
return (
<>
<PageTitle eyebrow="테일스케일" title="ACL" description="사용자 그룹과 장비 태그 기준의 tailnet 접근 정책을 관리합니다." />
<div className="grid gap-4 xl:grid-cols-3">
{[
['강사 장비 접근', 'group:instructors -> tag:teaching-device', '활성'],
['학생 워크스페이스', 'group:students -> tag:assigned-workspace', '활성'],
['운영자 전체 접근', 'group:ops -> tag:platform-admin', '활성']
].map(([name, rule, status]) => (
<Card key={name} className="p-5">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-semibold">{name}</p>
<Badge tone={getTone(status)}>{status}</Badge>
</div>
<p className="mt-3 text-xs text-muted-foreground">{rule}</p>
</Card>
))}
</div>
</>
);
}
function TailscaleDiagnosticsPage() {
return (
<>
<PageTitle
eyebrow="테일스케일"
title="연결 진단"
description="Tailnet, subnet route, 플랫폼 게이트웨이 연동 상태를 진단합니다."
actions={
<Button className="h-8 px-3 text-[13px]">
<Activity className="size-4" />
진단 실행
</Button>
}
/>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="진단 항목" description="런처에는 연결 정보만 제공하고, 실제 세부 설정은 여기에서 관리합니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">항목</th>
<th className="px-5 py-3 font-medium">Key</th>
<th className="px-5 py-3 font-medium">Value</th>
<th className="px-5 py-3 font-medium">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{tailscaleSettings.map((setting) => (
<tr key={setting.key} className="hover:bg-muted/20">
<td className="px-5 py-4">
<p className="font-medium">{setting.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{setting.description}</p>
</td>
<td className="px-5 py-4 font-medium">{setting.key}</td>
<td className="px-5 py-4 text-muted-foreground">{setting.value}</td>
<td className="px-5 py-4">
<Badge tone={getTone(setting.status)}>{setting.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
function DashboardPage({ onUserSessionsOpen }: { onUserSessionsOpen: () => void }) {
return (
<>
<PageTitle
eyebrow="플랫폼 운영"
title="대시보드"
description="런처 버전, 앱 활성화, 사용자, 세션, 라이센스, 릴리즈, 시크릿, 런타임, 공지사항을 한 콘솔에서 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]">
<SlidersHorizontal className="size-4" />
필터
</Button>
<Button className="h-8 px-3 text-[13px]">
<Plus className="size-4" />새 릴리즈
</Button>
</>
}
/>
<MetricCards />
<div className="mt-4 grid gap-4 xl:grid-cols-4">
<LauncherPanel />
<SessionsPanel onMore={onUserSessionsOpen} />
</div>
<div className="mt-4">
<AppRegistryTable />
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<ContainerPanel />
<NoticePanel />
</div>
</>
);
}
function App() {
const [activeView, setActiveView] = useState<ViewKey>('dashboard');
const [menuSearch, setMenuSearch] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(() => Boolean(localStorage.getItem('studio-admin-token')));
const renderView = () => {
switch (activeView) {
case 'launcherVersions':
return <LauncherManagementPage />;
case 'launcherApps':
return <LauncherAppsPage />;
case 'launcherCommonSettings':
return <LauncherCommonSettingsPage />;
case 'notices':
return <NoticesPage />;
case 'adminManagement':
return <AdminManagementPage />;
case 'userSessions':
return <UserSessionsPage />;
case 'licenses':
return <LicensesPage />;
case 'appManagement':
return <AppManagementPage />;
case 'appSources':
return <AppSourcesPage />;
case 'appBuilds':
return <BuildManagementPage />;
case 'appReleases':
return <ReleaseManagementPage />;
case 'appDeployments':
return <DeploymentManagementPage />;
case 'appMigrations':
return <MigrationManagementPage />;
case 'gatewayStatus':
return <GatewayStatusPage />;
case 'gatewayAgents':
return <GatewayAgentsPage />;
case 'gatewayDevices':
return <GatewayDeviceSyncPage />;
case 'tailscaleStatus':
return <TailscaleStatusPage />;
case 'tailscaleRoutes':
return <TailscaleRoutesPage />;
case 'tailscaleAcl':
return <TailscaleAclPage />;
case 'tailscaleDiagnostics':
return <TailscaleDiagnosticsPage />;
case 'agentRegistration':
return <AgentRegistrationPage />;
case 'agentInstallStatus':
return <AgentInstallStatusPage />;
case 'agentSshKeys':
return <AgentSshKeysPage />;
case 'agentWorkspace':
return <AgentWorkspacePage />;
case 'agentCommands':
return <AgentCommandsPage />;
case 'runtimeConfig':
return <RuntimeConfigApiPage />;
case 'runtimeLogs':
return <RuntimeLogsApiPage />;
case 'runtimeDeviceControl':
return <RuntimeDeviceControlPage />;
default:
return <DashboardPage onUserSessionsOpen={() => setActiveView('userSessions')} />;
}
};
if (!isAuthenticated) {
return <LoginPage onLogin={() => setIsAuthenticated(true)} />;
}
return (
<div className="min-h-screen overflow-hidden bg-background text-foreground">
<div className="min-h-screen lg:pl-72">
<Sidebar activeView={activeView} menuSearch={menuSearch} onViewChange={setActiveView} />
<div className="flex h-screen min-w-0 flex-col">
<Topbar menuSearch={menuSearch} onMenuSearchChange={setMenuSearch} />
<main className="scrollbar-stable min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[1600px] px-4 pb-16 pt-6 md:px-6 md:pb-20">
{renderView()}
</div>
</main>
</div>
</div>
</div>
);
}
export default App;