4511 lines
188 KiB
Plaintext
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;
|