Files
studio-admin-fe/src/App.tsx
T
chyoo 09480540ec
ci/woodpecker/push/woodpecker Pipeline was successful
add terms management for signup agreements
2026-07-10 14:21:39 +09:00

2411 lines
94 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Activity,
Camera,
ChevronDown,
ChevronRight,
CircleHelp,
Loader2,
LogOut,
Settings,
Search,
SlidersHorizontal,
Trash2
} from 'lucide-react';
import { navGroups } from './data/mock';
import { Badge, Button, Card, CardHeader, Modal, Progress } from './components/ui';
import { formatDateTime as formatDateTimeValue } from './lib/date-format';
import { apiBaseUrl, apiFetch, apiRequest, authExpiredEventName, authHeaders, clearAdminSession } from './lib/api';
import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
import { cn } from './lib/utils';
import { openPageAgent } from './lib/page-agent';
import { AppManagementPage as AppManagementApiPage } from './pages/applications/AppManagementPage';
import { BuildManagementPage } from './pages/applications/BuildManagementPage';
import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage';
import { MigrationManagementPage } from './pages/applications/MigrationManagementPage';
import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPage';
import { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage';
import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage';
import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage';
import { createAdminSshKey } from './pages/operations/operations-api';
import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage';
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
import { TermsPage as TermsApiPage } from './pages/operations/TermsPage';
import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage';
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
import { RuntimeLogsPage as RuntimeLogsApiPage } from './pages/runtime/RuntimeLogsPage';
import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage';
import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage';
import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage';
import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
import { LauncherLogsPage } from './pages/launcher/LauncherLogsPage';
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage';
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
import { CommonAppLogsPage } from './pages/common-apps/CommonAppLogsPage';
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
import {
InfrastructureClusterPage,
InfrastructureNetworkPage,
InfrastructureStoragePage,
InfrastructureWorkloadsPage
} from './pages/infrastructure/InfrastructurePages';
import { MonitoringPage } from './pages/system/MonitoringPage';
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
import {
AgentCommandsPage as AgentCommandsRestoredPage,
AgentInstallStatusPage as AgentInstallStatusRestoredPage,
AgentRegistrationPage as AgentRegistrationRestoredPage,
AgentSshKeysPage as AgentSshKeysRestoredPage,
AgentWorkspacePage as AgentWorkspaceRestoredPage,
GatewayAgentsPage as GatewayAgentsRestoredPage,
GatewayDevicesPage as GatewayDevicesRestoredPage,
GatewayStatusPage as GatewayStatusRestoredPage,
TailscaleAclPage as TailscaleAclRestoredPage,
TailscaleRoutesPage as TailscaleRoutesRestoredPage,
TailscaleStatusPage as TailscaleStatusRestoredPage
} from './pages/RestoredPages';
type ViewKey =
| 'dashboard'
| 'commonSources'
| 'commonBuilds'
| 'commonComponents'
| 'commonLogs'
| 'launcherSources'
| 'launcherBuilds'
| 'launcherVersions'
| 'launcherComponents'
| 'launcherLogs'
| 'launcherApps'
| 'notices'
| 'terms'
| 'appManagement'
| 'appRepositories'
| 'appSources'
| 'appBuilds'
| 'appReleases'
| 'appDeployments'
| 'appMigrations'
| 'gatewayStatus'
| 'gatewayAgents'
| 'gatewayDevices'
| 'tailscaleStatus'
| 'tailscaleRoutes'
| 'tailscaleAcl'
| 'infraCluster'
| 'infraWorkloads'
| 'infraNetwork'
| 'infraStorage'
| 'monitoring'
| 'agentRegistration'
| 'agentInstallStatus'
| 'agentSshKeys'
| 'agentWorkspace'
| 'agentCommands'
| 'runtimeConfig'
| 'runtimeLogs'
| 'runtimeDeviceControl'
| 'adminManagement'
| 'organizations'
| 'userSessions'
| 'licenses';
const viewByLabel: Record<string, ViewKey> = {
대시보드: 'dashboard',
'버전 관리': 'launcherVersions',
구성요소: 'launcherComponents',
'앱 연결': 'launcherApps',
공지사항: 'notices',
약관: 'terms',
'앱 관리': 'appManagement',
'저장소 관리': 'appRepositories',
'소스 관리': 'appSources',
'빌드 관리': 'appBuilds',
'릴리즈 관리': 'appReleases',
'실행 설정': 'runtimeConfig',
'배포 관리': 'appDeployments',
마이그레이션: 'appMigrations',
'연결 상태': 'gatewayStatus',
'Agent 관리': 'gatewayAgents',
'장비 동기화': 'gatewayDevices',
'노드 현황': 'tailscaleStatus',
라우트: 'tailscaleRoutes',
ACL: 'tailscaleAcl',
클러스터: 'infraCluster',
워크로드: 'infraWorkloads',
네트워크: 'infraNetwork',
스토리지: 'infraStorage',
모니터링: 'monitoring',
'장비 등록': 'agentRegistration',
'설치 상태': 'agentInstallStatus',
'SSH 키 배포': 'agentSshKeys',
'Workspace 설정': 'agentWorkspace',
'원격 명령': 'agentCommands',
'로그/이벤트': 'runtimeLogs',
'장비 제어': 'runtimeDeviceControl',
'관리자 관리': 'adminManagement',
'조직 관리': 'organizations',
'사용자/세션': 'userSessions',
라이센스: 'licenses'
};
const pathByView: Record<ViewKey, string> = {
dashboard: '/dashboard',
commonSources: '/common-apps/sources',
commonBuilds: '/common-apps/builds',
commonComponents: '/common-apps/components',
commonLogs: '/common-apps/logs',
launcherSources: '/launcher/sources',
launcherBuilds: '/launcher/builds',
launcherVersions: '/launcher/versions',
launcherComponents: '/launcher/components',
launcherLogs: '/launcher/logs',
launcherApps: '/launcher/apps',
notices: '/operations/notices',
terms: '/operations/terms',
appManagement: '/applications/apps',
appRepositories: '/applications/repositories',
appSources: '/applications/sources',
appBuilds: '/applications/builds',
appReleases: '/applications/releases',
appDeployments: '/applications/deployments',
appMigrations: '/applications/migrations',
gatewayStatus: '/gateway/status',
gatewayAgents: '/gateway/agents',
gatewayDevices: '/gateway/devices',
tailscaleStatus: '/tailscale/status',
tailscaleRoutes: '/tailscale/routes',
tailscaleAcl: '/tailscale/acl',
infraCluster: '/infrastructure/cluster',
infraWorkloads: '/infrastructure/workloads',
infraNetwork: '/infrastructure/network',
infraStorage: '/infrastructure/storage',
monitoring: '/system/monitoring',
agentRegistration: '/agents/registration',
agentInstallStatus: '/agents/install-status',
agentSshKeys: '/agents/ssh-keys',
agentWorkspace: '/agents/workspace',
agentCommands: '/agents/commands',
runtimeConfig: '/applications/runtime-settings',
runtimeLogs: '/runtime/logs',
runtimeDeviceControl: '/runtime/devices',
adminManagement: '/operations/admins',
organizations: '/operations/organizations',
userSessions: '/operations/users',
licenses: '/operations/licenses'
};
const viewByPath = Object.fromEntries(
Object.entries(pathByView).map(([view, path]) => [path, view as ViewKey])
) as Record<string, ViewKey>;
function viewFromPath(pathname: string): ViewKey {
if (pathname === '/') return 'dashboard';
return viewByPath[pathname] ?? 'dashboard';
}
type CurrentAdmin = {
id?: string;
email: string;
name?: string;
role?: string;
avatarUrl?: string | null;
appPermissions?: string[];
sshKeys?: Array<{
id: string;
label?: string | null;
fingerprint?: string | null;
giteaUsername?: string | null;
createdAt?: string | null;
}>;
};
function readCurrentAdmin(): CurrentAdmin | null {
try {
const raw = localStorage.getItem('studio-admin');
if (!raw) return null;
const admin = JSON.parse(raw) as CurrentAdmin;
return admin?.email ? admin : null;
} catch {
return null;
}
}
async function fetchCurrentAdmin() {
const response = await apiFetch(`${apiBaseUrl}/auth/admin/me`, { headers: authHeaders() });
if (!response.ok) throw new Error('Admin profile request failed');
const data = await response.json() as { admin: CurrentAdmin };
return data.admin;
}
function storeCurrentAdmin(admin: CurrentAdmin) {
localStorage.setItem('studio-admin', JSON.stringify(admin));
}
async function resizeImageToWebp(file: File, size = 128) {
if (!file.type.startsWith('image/')) {
throw new Error('이미지 파일만 업로드할 수 있습니다.');
}
const sourceUrl = URL.createObjectURL(file);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = sourceUrl;
});
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('이미지 처리에 실패했습니다.');
}
const sourceSize = Math.min(image.width, image.height);
const sourceX = (image.width - sourceSize) / 2;
const sourceY = (image.height - sourceSize) / 2;
ctx.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size);
const qualitySteps = [0.82, 0.72, 0.62, 0.52];
for (const quality of qualitySteps) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
if (blob && blob.size <= 80 * 1024) return blob;
}
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', 0.45));
if (!blob) throw new Error('이미지 변환에 실패했습니다.');
return blob;
} finally {
URL.revokeObjectURL(sourceUrl);
}
}
type ApiAppComponent = {
id: string;
appId: string;
name: string;
type: string;
repository?: string | null;
branch?: string | null;
buildTarget?: string | null;
app?: ApiApp;
};
type ApiApp = {
id: string;
key: string;
name: string;
description?: string | null;
namespace?: string | null;
status: string;
components?: ApiAppComponent[];
};
type ApiLauncherVersion = {
id: string;
version: string;
minimumVersion?: string | null;
repository?: string | null;
branch?: string | null;
commitSha?: string | null;
artifact: string;
downloadUrl: string;
releaseNotes?: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
};
type ApiAppRelease = {
id: string;
appId: string;
version: string;
branch: string;
artifact?: string | null;
status: string;
createdAt: string;
updatedAt?: string | null;
app?: ApiApp;
};
type CommonAppPipeline = {
id: string;
repository: string;
message: string;
branch: string;
commitSha?: string | null;
durationSeconds?: number | null;
status: string;
createdAt: number;
url?: string | null;
};
type ApiNotice = {
id: string;
appKey: string;
title: string;
body: string;
linkUrl?: string | null;
isActive: boolean;
startsAt?: string | null;
endsAt?: string | null;
priority?: number;
status?: string;
createdAt: string;
updatedAt?: string | null;
};
type RuntimeResource = {
kind: string;
name: string;
status: string;
ready: string;
restarts: number | null;
age: string;
detail: string;
};
type RuntimeResourceResponse = {
namespace: string;
exists: boolean;
summary: {
pods: number;
deployments: number;
statefulSets: number;
services: number;
persistentVolumeClaims: number;
};
resources: RuntimeResource[];
};
type RuntimePodMetric = {
namespace: string;
pod: string;
cpu: string;
memory: string;
};
type ClusterMetricValue = {
usage: string | null;
capacity: string | null;
percent: number | null;
};
type ClusterNodeMetric = {
name: string;
status: string;
cpu: ClusterMetricValue;
memory: ClusterMetricValue;
storage: ClusterMetricValue;
};
type ClusterMetrics = {
nodes: ClusterNodeMetric[];
summary: {
cpuPercent: number | null;
memoryPercent: number | null;
storagePercent: number | null;
};
};
type DashboardJob = {
id: string;
status: string;
};
type ActiveWorkSummary = {
builds: number;
releases: number;
deployments: number;
};
type DashboardRuntimeResource = RuntimeResource & {
namespace: string;
appName: string;
cpu?: string;
memory?: string;
};
type UserSessionRow = {
id: string;
no?: number;
sessionId: string | null;
userId: string;
user: string;
email?: string;
role: string;
session: string;
createdAt?: string | null;
expiresAt: string | null;
lastSeen: string | null;
state: string;
};
function formatDateTime(value?: string | null) {
return formatDateTimeValue(value);
}
function loadLauncherVersions() {
return apiRequest<ApiLauncherVersion[]>('/launcher/versions');
}
function loadApps() {
return apiRequest<ApiApp[]>('/apps')
.then(filterAppsByCurrentAdmin);
}
function loadAppReleases() {
return apiRequest<ApiAppRelease[]>('/apps/releases');
}
function loadUserSessions() {
return apiRequest<{ sessions?: UserSessionRow[] }>('/users/sessions')
.then((response) => (response.sessions ?? []).filter((session) => session.sessionId));
}
function loadNotices() {
return apiRequest<{ notices?: ApiNotice[] }>('/notices')
.then((response) => response.notices ?? []);
}
function loadCommonAppPipelines() {
return apiRequest<{ repos: CommonAppPipeline[] }>('/woodpecker/repos')
.then((result) => result.repos ?? []);
}
function isActiveWorkStatus(status?: string | null) {
return status === 'PENDING' || status === 'RUNNING';
}
function loadActiveWorkSummary(releases: ApiAppRelease[]): Promise<ActiveWorkSummary> {
const activeReleases = releases.filter((release) => isActiveWorkStatus(release.status)).length;
return Promise.allSettled([
apiRequest<{ jobs?: DashboardJob[] }>('/apps/builds?page=1&limit=20'),
apiRequest<{ deployments?: DashboardJob[] }>('/apps/deployments?page=1&limit=20')
]).then(([buildsResult, deploymentsResult]) => ({
builds: buildsResult.status === 'fulfilled'
? (buildsResult.value.jobs ?? []).filter((job) => isActiveWorkStatus(job.status)).length
: 0,
releases: activeReleases,
deployments: deploymentsResult.status === 'fulfilled'
? (deploymentsResult.value.deployments ?? []).filter((job) => isActiveWorkStatus(job.status)).length
: 0
}));
}
function loadRuntimeNamespaceResources(namespace: string) {
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
}
function loadRuntimePodMetrics(namespace: string) {
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`);
}
function loadClusterMetrics() {
return apiRequest<ClusterMetrics>('/runtime/metrics/cluster');
}
const statusTone = {
success: 'green',
succeeded: 'green',
done: 'green',
running: 'blue',
started: 'blue',
pending: 'amber',
blocked: 'amber',
created: 'amber',
failure: 'red',
failed: 'red',
error: 'red',
killed: 'red',
활성: 'green',
'실행 중': 'green',
게시됨: 'green',
카나리: 'blue',
예약됨: 'blue',
점검: 'amber',
주의: 'amber',
드레이닝: 'amber',
준비중: 'amber',
배포됨: 'green',
초안: 'neutral',
대기: 'neutral',
만료: 'neutral',
종료: 'neutral',
'세션 없음': 'neutral',
비활성: 'neutral',
'지원 종료': 'red',
잠김: 'red',
구성완료: 'green',
검토필요: 'amber',
완료: 'green',
성공: 'green',
실패: 'red',
취소: 'neutral'
} as const;
function getTone(status: string) {
return statusTone[status as keyof typeof statusTone] ?? 'neutral';
}
function formatPipelineStatus(status?: string | null) {
const labels: Record<string, string> = {
success: '성공',
succeeded: '성공',
done: '성공',
running: '실행 중',
started: '실행 중',
pending: '대기',
blocked: '대기',
created: '생성됨',
failure: '실패',
failed: '실패',
error: '오류',
killed: '중단'
};
const normalized = (status ?? '').toLowerCase();
return labels[normalized] ?? status ?? '-';
}
function formatRelativeTime(timestamp: number) {
if (!timestamp) return '-';
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds < 60) return '방금 전';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}분 전`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}일 전`;
return new Intl.DateTimeFormat('ko-KR', { month: 'short', day: 'numeric' }).format(new Date(timestamp));
}
type NavGroup = (typeof navGroups)[number];
type NavItem = NavGroup['items'][number];
type NavChild = { label: string; view?: string };
function resolveNavView(item: NavItem | NavChild): ViewKey | undefined {
return 'view' in item && item.view ? item.view as ViewKey : viewByLabel[item.label];
}
function isOwnerRole(role?: string | null) {
return role === 'OWNER';
}
function isViewerRole(role?: string | null) {
return role === 'VIEWER';
}
function filterNavGroupsByRole(groups: typeof navGroups, role?: string | null) {
const isOwner = isOwnerRole(role);
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => isOwner || item.label !== '관리자 관리')
}))
.filter((group) => group.items.length > 0);
}
function pageAgentAccessAttrs(allowed: boolean) {
return allowed
? {}
: {
'data-page-agent-ignore': 'true',
'data-page-agent-not-interactive': 'true'
};
}
function isTrustedClick(event: ReactMouseEvent<HTMLElement>) {
return event.nativeEvent.isTrusted;
}
function Sidebar({
activeView,
menuSearch,
currentAdmin,
onViewChange
}: {
activeView: ViewKey;
menuSearch: string;
currentAdmin: CurrentAdmin | null;
onViewChange: (view: ViewKey) => void;
}) {
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
const normalizedMenuSearch = menuSearch.trim().toLowerCase();
const roleNavGroups = useMemo(
() => filterNavGroupsByRole(navGroups, currentAdmin?.role) as typeof navGroups,
[currentAdmin?.role]
);
useEffect(() => {
for (const group of roleNavGroups) {
for (const item of group.items) {
if (!('children' in item) || !item.children) continue;
const hasActiveChild = item.children.some((child) => resolveNavView(child) === activeView);
if (!hasActiveChild) continue;
setOpenMenus((prev) => (prev[item.label] ? prev : { ...prev, [item.label]: true }));
return;
}
}
}, [activeView, roleNavGroups]);
const visibleGroups = normalizedMenuSearch
? roleNavGroups
.map((group) => {
const groupMatches = group.label.toLowerCase().includes(normalizedMenuSearch);
const items = groupMatches
? group.items
: group.items.flatMap((item) => {
const itemMatches = item.label.toLowerCase().includes(normalizedMenuSearch);
if ('children' in item && item.children) {
const matchedChildren = item.children.filter((child) => child.label.toLowerCase().includes(normalizedMenuSearch));
if (itemMatches) return [item];
if (matchedChildren.length) return [{ ...item, children: matchedChildren } as NavItem];
return [];
}
return itemMatches ? [item] : [];
});
return { ...group, items };
})
.filter((group) => group.items.length > 0)
: roleNavGroups;
return (
<aside className="fixed inset-y-0 left-0 hidden w-[17rem] flex-col border-r border-border bg-sidebar px-3 py-4 lg:flex">
<div className="mb-8 shrink-0 px-4 py-3 text-center">
<img className="mx-auto h-5 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
<p className="mt-2 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Studio Management System
</p>
</div>
<nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
{visibleGroups.map((group) => {
const isPageAgentAllowedGroup = group.label === '플랫폼';
return (
<div key={group.label} {...pageAgentAccessAttrs(isPageAgentAllowedGroup)}>
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
<div className="space-y-1">
{group.items.map((item) => {
const hasChildren = 'children' in item && item.children;
const isParentActive = hasChildren
? item.children.some((child) => resolveNavView(child) === activeView)
: activeView === resolveNavView(item);
const isMenuOpen = Boolean(normalizedMenuSearch) || (openMenus[item.label] ?? isParentActive);
return (
<div key={item.label}>
<button
type="button"
{...pageAgentAccessAttrs(isPageAgentAllowedGroup)}
onClick={(event) => {
if (!isPageAgentAllowedGroup && !isTrustedClick(event)) return;
if (hasChildren) {
setOpenMenus((prev) => ({ ...prev, [item.label]: !prev[item.label] }));
return;
}
const view = resolveNavView(item);
if (view) onViewChange(view);
}}
className={cn(
'flex h-9 w-full items-center gap-2 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
isParentActive && 'font-semibold text-white'
)}
>
<item.icon className={cn('size-4', isParentActive && 'text-white')} />
<span className="min-w-0 flex-1 text-left">{item.label}</span>
{hasChildren ? (
isMenuOpen ? (
<ChevronDown className={cn('size-3.5 text-muted-foreground', isParentActive && 'text-white')} />
) : (
<ChevronRight className={cn('size-3.5 text-muted-foreground', isParentActive && 'text-white')} />
)
) : null}
</button>
{hasChildren && isMenuOpen ? (
<div className="mt-1 space-y-1 pl-6">
{item.children.map((child) => {
const isChildActive = activeView === resolveNavView(child);
return (
<button
type="button"
key={child.label}
{...pageAgentAccessAttrs(isPageAgentAllowedGroup)}
onClick={(event) => {
if (!isPageAgentAllowedGroup && !isTrustedClick(event)) return;
const view = resolveNavView(child);
if (view) onViewChange(view);
}}
className={cn(
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[12.5px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
isChildActive && 'font-semibold text-white'
)}
>
<span className={cn('h-3 w-1 rounded-full', isChildActive ? 'bg-white' : 'bg-transparent')} />
<span>{child.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
</div>
);
})}
{visibleGroups.length === 0 ? (
<div className="px-2 py-6 text-center text-xs text-muted-foreground">검색된 메뉴가 없습니다.</div>
) : null}
</nav>
</aside>
);
}
function PageTitle({
eyebrow,
title,
description,
actions
}: {
eyebrow: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm text-muted-foreground">{eyebrow}</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight md:text-3xl">{title}</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
);
}
function Topbar({
menuSearch,
onMenuSearchChange,
currentAdmin,
onAdminChange,
onLogout
}: {
menuSearch: string;
onMenuSearchChange: (value: string) => void;
currentAdmin: CurrentAdmin | null;
onAdminChange: (admin: CurrentAdmin) => void;
onLogout: () => void;
}) {
const [profileOpen, setProfileOpen] = useState(false);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [avatarDeleteConfirmOpen, setAvatarDeleteConfirmOpen] = useState(false);
const [profileSshModalOpen, setProfileSshModalOpen] = useState(false);
const [profileSshDeleteConfirmOpen, setProfileSshDeleteConfirmOpen] = useState(false);
const [avatarFailed, setAvatarFailed] = useState(false);
const [avatarRevision, setAvatarRevision] = useState(0);
const [avatarUploading, setAvatarUploading] = useState(false);
const [avatarDeleting, setAvatarDeleting] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordMessage, setPasswordMessage] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [profileSshLabel, setProfileSshLabel] = useState('');
const [profileSshPublicKey, setProfileSshPublicKey] = useState('');
const [profileSshSaving, setProfileSshSaving] = useState(false);
const [profileSshDeleting, setProfileSshDeleting] = useState(false);
const [profileSshError, setProfileSshError] = useState<string | null>(null);
const profileRef = useRef<HTMLDivElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const accountLabel = currentAdmin?.email ?? '관리자';
const avatarLetter = accountLabel.trim().charAt(0).toUpperCase() || 'A';
const avatarUrl = currentAdmin?.avatarUrl && !avatarFailed ? currentAdmin.avatarUrl : '';
const avatarSrc = avatarUrl ? `${avatarUrl}${avatarUrl.includes('?') ? '&' : '?'}v=${avatarRevision}` : '';
const roleLabel = currentAdmin?.role ?? '-';
const appPermissionCount = currentAdmin?.appPermissions?.length ?? 0;
const sshKeys = currentAdmin?.sshKeys ?? [];
const openProfileSshModal = () => {
const account = currentAdmin?.email.split('@')[0] || 'admin';
const random = Math.random().toString(36).slice(2, 8);
setProfileSshLabel(`${account}-${random}-key`);
setProfileSshPublicKey('');
setProfileSshError(null);
setProfileSshModalOpen(true);
};
const closeProfileSshModal = () => {
setProfileSshModalOpen(false);
setProfileSshLabel('');
setProfileSshPublicKey('');
setProfileSshError(null);
};
const closePasswordModal = () => {
setPasswordModalOpen(false);
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirm('');
setPasswordError(null);
setPasswordMessage(null);
};
useEffect(() => {
setAvatarFailed(false);
setAvatarError(null);
}, [currentAdmin?.avatarUrl]);
const refreshAdmin = async () => {
const admin = await fetchCurrentAdmin();
storeCurrentAdmin(admin);
onAdminChange(admin);
setAvatarFailed(false);
setAvatarRevision((revision) => revision + 1);
};
const uploadAvatar = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file || avatarUploading || avatarDeleting) return;
setAvatarUploading(true);
setAvatarError(null);
try {
const imageBlob = await resizeImageToWebp(file, 128);
const response = await apiFetch(`${apiBaseUrl}/auth/admin/avatar`, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': imageBlob.type }),
body: imageBlob,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '프로필 이미지 업로드에 실패했습니다.');
}
await refreshAdmin();
} catch (error) {
setAvatarError(error instanceof Error ? error.message : '프로필 이미지 업로드에 실패했습니다.');
} finally {
setAvatarUploading(false);
}
};
const deleteAvatar = async () => {
if (!avatarUrl || avatarUploading || avatarDeleting) return;
setAvatarDeleting(true);
setAvatarError(null);
try {
const response = await apiFetch(`${apiBaseUrl}/auth/admin/avatar`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '프로필 이미지 삭제에 실패했습니다.');
}
await refreshAdmin();
setAvatarDeleteConfirmOpen(false);
} catch (error) {
setAvatarError(error instanceof Error ? error.message : '프로필 이미지 삭제에 실패했습니다.');
} finally {
setAvatarDeleting(false);
}
};
const submitPasswordChange = async () => {
setPasswordMessage(null);
setPasswordError(null);
if (!currentPassword || !newPassword || !newPasswordConfirm) {
setPasswordError('비밀번호를 모두 입력해 주세요.');
return;
}
if (newPassword.length < 8) {
setPasswordError('새 비밀번호는 8자 이상이어야 합니다.');
return;
}
if (newPassword !== newPasswordConfirm) {
setPasswordError('새 비밀번호 확인이 일치하지 않습니다.');
return;
}
setPasswordSaving(true);
try {
const response = await apiFetch(`${apiBaseUrl}/auth/admin/password`, {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '비밀번호 변경에 실패했습니다.');
}
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirm('');
setPasswordMessage('비밀번호가 변경되었습니다.');
} catch (error) {
setPasswordError(error instanceof Error ? error.message : '비밀번호 변경에 실패했습니다.');
} finally {
setPasswordSaving(false);
}
};
const submitProfileSshKey = async () => {
if (!currentAdmin?.id || !profileSshPublicKey.trim() || profileSshSaving) return;
setProfileSshSaving(true);
setProfileSshError(null);
try {
await createAdminSshKey(currentAdmin.id, {
label: profileSshLabel.trim() || undefined,
publicKey: profileSshPublicKey.trim(),
});
await refreshAdmin();
closeProfileSshModal();
} catch (error) {
setProfileSshError(error instanceof Error ? error.message : 'SSH 키 등록에 실패했습니다.');
} finally {
setProfileSshSaving(false);
}
};
const deleteProfileSshKey = async () => {
const keyId = sshKeys[0]?.id;
if (!currentAdmin?.id || !keyId || profileSshDeleting) return;
setProfileSshDeleting(true);
setProfileSshError(null);
try {
const response = await apiFetch(`${apiBaseUrl}/admins/${currentAdmin.id}/ssh-keys/${keyId}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || 'SSH 키 삭제에 실패했습니다.');
}
await refreshAdmin();
setProfileSshDeleteConfirmOpen(false);
} catch (error) {
setProfileSshError(error instanceof Error ? error.message : 'SSH 키 삭제에 실패했습니다.');
} finally {
setProfileSshDeleting(false);
}
};
useEffect(() => {
const closeProfile = (event: MouseEvent) => {
if (!profileRef.current?.contains(event.target as Node)) setProfileOpen(false);
};
document.addEventListener('mousedown', closeProfile);
return () => document.removeEventListener('mousedown', closeProfile);
}, []);
return (
<>
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-border bg-background/90 px-4 backdrop-blur-xl md:px-6">
<div className="flex min-w-0 items-center gap-3">
<Button variant="outline" className="lg:hidden">
<SlidersHorizontal className="size-4" />
</Button>
<div className="relative hidden w-80 md:block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="메뉴 검색"
value={menuSearch}
onChange={(event) => onMenuSearchChange(event.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" className="size-9 px-0" title="AI 도움말" aria-label="AI 도움말" onClick={openPageAgent}>
<CircleHelp className="size-4" />
</Button>
<div ref={profileRef} className="relative">
<Button variant="outline" className="gap-2" onClick={() => setProfileOpen((open) => !open)}>
<span className="hidden max-w-48 truncate md:inline">{accountLabel}</span>
<ChevronDown className="size-4 text-muted-foreground" />
</Button>
{profileOpen ? (
<div className="absolute right-0 top-11 z-50 w-64 overflow-hidden rounded-md border border-border bg-card shadow-xl">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<span className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-sm font-semibold">
{avatarUrl ? (
<img className="h-full w-full object-cover" src={avatarSrc} alt="" onError={() => setAvatarFailed(true)} />
) : avatarLetter}
</span>
<div className="min-w-0">
<p className="truncate text-sm text-foreground">{currentAdmin?.name || accountLabel}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
</div>
</div>
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
onClick={() => {
setProfileOpen(false);
setProfileSettingsOpen(true);
setAvatarError(null);
setPasswordError(null);
setPasswordMessage(null);
}}
>
<Settings className="size-4" />
프로필 설정
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
onClick={() => {
setProfileOpen(false);
onLogout();
}}
>
<LogOut className="size-4" />
로그아웃
</button>
</div>
) : null}
</div>
</div>
</header>
<Modal
open={profileSettingsOpen}
title="프로필 설정"
onClose={() => setProfileSettingsOpen(false)}
className="max-w-xl"
footer={<Button variant="outline" onClick={() => setProfileSettingsOpen(false)}>닫기</Button>}
>
<div className="space-y-6">
<section className="flex items-center gap-4 rounded-md border border-border bg-muted/10 p-4">
<div className="relative size-20 shrink-0">
<span className="flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-muted text-xl font-semibold">
{avatarUrl ? (
<img className="h-full w-full object-cover" src={avatarSrc} alt="" onError={() => setAvatarFailed(true)} />
) : avatarLetter}
</span>
{avatarUrl ? (
<button
type="button"
className="absolute right-0 top-0 z-10 flex size-7 items-center justify-center rounded-full border border-rose-400/70 bg-card text-rose-300 shadow-sm transition-colors hover:bg-rose-500/15 hover:text-rose-200 disabled:cursor-not-allowed disabled:opacity-60"
title="프로필 이미지 삭제"
aria-label="프로필 이미지 삭제"
disabled={avatarUploading || avatarDeleting}
onClick={() => setAvatarDeleteConfirmOpen(true)}
>
{avatarDeleting ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
</button>
) : null}
<button
type="button"
className="absolute bottom-0 right-0 flex size-8 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:border-primary/60 hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
title="프로필 이미지 업로드"
aria-label="프로필 이미지 업로드"
disabled={avatarUploading || avatarDeleting}
onClick={() => avatarInputRef.current?.click()}
>
{avatarUploading ? <Loader2 className="size-4 animate-spin" /> : <Camera className="size-4" />}
</button>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={uploadAvatar}
/>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{currentAdmin?.name || accountLabel}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
{avatarError ? <p className="mt-2 line-clamp-2 text-xs text-rose-300">{avatarError}</p> : null}
</div>
</section>
<section>
<h3 className="text-sm font-semibold text-foreground">권한 정보</h3>
<div className="mt-3 grid gap-3">
<ProfileInfoRow label="역할" value={roleLabel} />
<ProfileInfoRow label="앱 권한" value={isOwnerRole(currentAdmin?.role) ? '전체 앱' : `${appPermissionCount}개 앱`} />
</div>
</section>
<section className="rounded-md border border-border bg-muted/10 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">SSH 키</h3>
<p className="mt-1 text-xs text-muted-foreground">관리자 계정에 등록된 저장소 접근 키입니다.</p>
</div>
<div className="flex items-center gap-2">
{sshKeys.length > 0 ? (
<Button
variant="outline"
className="h-8 border-rose-500/40 px-3 text-xs text-rose-200 hover:bg-rose-500/10"
disabled={!currentAdmin?.id}
onClick={() => {
setProfileSshError(null);
setProfileSshDeleteConfirmOpen(true);
}}
>
삭제
</Button>
) : null}
{!sshKeys.length ? (
<Button variant="outline" className="h-8 px-3 text-xs" disabled={!currentAdmin?.id} onClick={openProfileSshModal}>
등록
</Button>
) : null}
</div>
</div>
<div className="mt-4 rounded-md border border-border bg-background/40">
{sshKeys.length ? sshKeys.map((key) => (
<div key={key.id} className="border-b border-border px-3 py-2 last:border-b-0">
<p className="truncate text-sm font-semibold text-foreground">{key.label ?? 'SSH Key'}</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{key.fingerprint ?? '-'}</p>
{key.giteaUsername ? <p className="mt-1 text-xs text-muted-foreground">저장소 사용자명 {key.giteaUsername}</p> : null}
</div>
)) : (
<p className="px-3 py-4 text-sm text-muted-foreground">등록된 SSH 키가 없습니다.</p>
)}
</div>
</section>
<section className="flex items-center justify-between gap-4 rounded-md border border-border bg-muted/10 p-4">
<div>
<h3 className="text-sm font-semibold text-foreground">비밀번호</h3>
<p className="mt-1 text-xs text-muted-foreground">현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</p>
</div>
<Button
variant="outline"
onClick={() => {
setPasswordModalOpen(true);
setPasswordError(null);
setPasswordMessage(null);
}}
>
비밀번호 변경
</Button>
</section>
</div>
</Modal>
<Modal
open={avatarDeleteConfirmOpen}
title="프로필 이미지 삭제"
onClose={() => setAvatarDeleteConfirmOpen(false)}
footer={
<>
<Button variant="outline" onClick={() => setAvatarDeleteConfirmOpen(false)}>취소</Button>
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" disabled={avatarDeleting} onClick={deleteAvatar}>
{avatarDeleting ? '삭제 중' : '삭제'}
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">현재 프로필 이미지를 삭제합니다. 삭제 후 기본 이니셜 이미지로 표시됩니다.</p>
{avatarError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{avatarError}</p> : null}
</div>
</Modal>
<Modal
open={profileSshModalOpen}
title="SSH 키 등록"
onClose={closeProfileSshModal}
className="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={closeProfileSshModal}>취소</Button>
<Button disabled={profileSshSaving || !profileSshPublicKey.trim()} onClick={submitProfileSshKey}>
{profileSshSaving ? '등록 중' : '등록'}
</Button>
</>
}
>
<div className="grid gap-4">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">키 이름</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={profileSshLabel}
onChange={(event) => setProfileSshLabel(event.target.value)}
/>
</label>
<p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
저장소 사용자명은 관리자 이메일 기준으로 자동 적용됩니다.
</p>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">공개키</span>
<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={profileSshPublicKey}
onChange={(event) => setProfileSshPublicKey(event.target.value)}
/>
</label>
{profileSshError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{profileSshError}</p> : null}
</div>
</Modal>
<Modal
open={profileSshDeleteConfirmOpen}
title="SSH 키 삭제"
onClose={() => setProfileSshDeleteConfirmOpen(false)}
footer={
<>
<Button variant="outline" onClick={() => setProfileSshDeleteConfirmOpen(false)}>취소</Button>
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" disabled={profileSshDeleting} onClick={deleteProfileSshKey}>
{profileSshDeleting ? '삭제 중' : '삭제'}
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">등록된 SSH 키를 삭제합니다. Gitea에 등록된 키도 함께 삭제됩니다.</p>
{profileSshError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{profileSshError}</p> : null}
</div>
</Modal>
<Modal
open={passwordModalOpen}
title="비밀번호 변경"
onClose={closePasswordModal}
footer={
<>
<Button variant="outline" onClick={closePasswordModal}>취소</Button>
<Button
disabled={passwordSaving || !currentPassword || !newPassword || !newPasswordConfirm}
onClick={submitPasswordChange}
>
{passwordSaving ? '변경 중' : '변경'}
</Button>
</>
}
>
<div className="grid gap-4">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">현재 비밀번호</span>
<input
type="password"
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">새 비밀번호</span>
<input
type="password"
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">새 비밀번호 확인</span>
<input
type="password"
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
value={newPasswordConfirm}
onChange={(event) => setNewPasswordConfirm(event.target.value)}
/>
</label>
{passwordError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{passwordError}</p> : null}
{passwordMessage ? <p className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">{passwordMessage}</p> : null}
</div>
</Modal>
</>
);
}
function ProfileInfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border bg-muted/10 px-3 py-2">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-medium text-foreground">{value}</p>
</div>
);
}
function LoginPage({ onLogin }: { onLogin: (admin: CurrentAdmin) => void }) {
const savedEmail = localStorage.getItem('studio-admin-saved-email') ?? '';
const [email, setEmail] = useState(savedEmail);
const [password, setPassword] = useState('');
const [rememberEmail, setRememberEmail] = useState(Boolean(savedEmail));
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const submitLogin = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!email.trim() || !password.trim()) return;
setLoginLoading(true);
setLoginError(false);
fetch(`${apiBaseUrl}/auth/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password })
})
.then((response) => {
if (!response.ok) throw new Error('Admin login failed');
return response.json() as Promise<{ token: string; expiresAt: string; admin: CurrentAdmin }>;
})
.then((data) => {
localStorage.setItem('studio-admin-token', data.token);
localStorage.setItem('studio-admin', JSON.stringify(data.admin));
if (rememberEmail) {
localStorage.setItem('studio-admin-saved-email', email.trim());
} else {
localStorage.removeItem('studio-admin-saved-email');
}
setLoginLoading(false);
onLogin(data.admin);
})
.catch(() => {
setLoginLoading(false);
setLoginError(true);
});
};
return (
<div className="min-h-screen bg-background px-4 text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-sm items-center justify-center py-12">
<div className="w-full rounded-lg border border-border bg-card/95 shadow-2xl">
<div className="px-6 pb-6 pt-8">
<div className="flex justify-center">
<img className="h-6 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
</div>
<p className="mt-2 text-center text-[9px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Studio Management System
</p>
<div className="mt-7 text-center">
<h1 className="text-xl font-semibold tracking-tight">관리자 로그인</h1>
<p className="mt-2 text-sm text-muted-foreground">계정 정보를 입력해 주세요.</p>
</div>
</div>
<form className="space-y-5 px-6 py-8" autoComplete="off" onSubmit={submitLogin}>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">이메일</span>
<input
type="email"
autoComplete="off"
className="login-light-field mt-2 h-10 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff' }}
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">비밀번호</span>
<input
id="password"
name="password"
type="password"
autoComplete="off"
className="login-light-field login-password-input mt-2 h-10 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff' }}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{loginError ? (
<p className="text-center text-xs text-rose-300">이메일 또는 비밀번호를 확인해 주세요.</p>
) : null}
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
className="size-4 accent-primary"
checked={rememberEmail}
onChange={(event) => setRememberEmail(event.target.checked)}
/>
아이디 저장
</label>
<button
type="submit"
className="mt-5 h-10 w-full rounded-md text-sm font-bold transition-colors hover:bg-[#256fdb] disabled:pointer-events-none disabled:opacity-45"
style={{ backgroundColor: '#387ef0', color: '#ffffff', WebkitTextFillColor: '#ffffff' }}
disabled={!email.trim() || !password.trim() || loginLoading}
>
<span style={{ color: '#ffffff', WebkitTextFillColor: '#ffffff' }}>
{loginLoading ? '확인 중' : '로그인'}
</span>
</button>
</form>
</div>
</div>
</div>
);
}
function isActiveStatus(status?: string | null) {
return ['활성', 'ACTIVE', 'READY', 'RUNNING', 'SUCCEEDED'].includes(status ?? '');
}
function getLatestReleaseByApp(releases: ApiAppRelease[]) {
return releases.reduce<Record<string, ApiAppRelease>>((acc, release) => {
const current = acc[release.appId];
if (!current || new Date(release.createdAt).getTime() > new Date(current.createdAt).getTime()) {
acc[release.appId] = release;
}
return acc;
}, {});
}
function isRuntimeResourceHealthy(resource: DashboardRuntimeResource) {
if (['Error', 'CrashLoopBackOff', 'Failed'].includes(resource.status)) return false;
if (!resource.ready.includes('/')) return ['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(resource.status);
const [ready, total] = resource.ready.split('/').map((value) => Number(value));
return Number.isFinite(ready) && Number.isFinite(total) && total > 0 && ready === total;
}
function isIgnoredRuntimeResource(resource: DashboardRuntimeResource) {
if (!['Pod', 'Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind)) return true;
const normalizedName = resource.name.toLowerCase();
const normalizedStatus = resource.status.toLowerCase();
if (resource.kind === 'Pod' && normalizedStatus === 'completed') return true;
if (resource.kind === 'Pod' && normalizedName.includes('migration') && ['completed', 'succeeded', 'success'].includes(normalizedStatus)) {
return true;
}
return false;
}
function formatJobStatus(status?: string | null) {
const labels: Record<string, string> = {
PENDING: '대기',
RUNNING: '실행 중',
SUCCEEDED: '성공',
FAILED: '실패',
CANCELED: '취소'
};
return status ? labels[status] ?? status : '-';
}
function SkeletonBlock({ className }: { className?: string }) {
return (
<div
className={cn(
'relative overflow-hidden rounded-md bg-white/15',
'before:absolute before:inset-0 before:-translate-x-full before:animate-[skeleton-shimmer_1.35s_ease-in-out_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/35 before:to-transparent',
className
)}
/>
);
}
function MetricCards({
apps: dashboardApps,
sessions,
activeWork,
runtimeResources,
loading
}: {
apps: ApiApp[];
sessions: UserSessionRow[];
activeWork: ActiveWorkSummary;
runtimeResources: DashboardRuntimeResource[];
loading: boolean;
}) {
const activeApps = dashboardApps.filter((app) => isActiveStatus(app.status)).length;
const activeSessions = sessions.filter((session) => session.state === '활성').length;
const activeWorkTotal = activeWork.builds + activeWork.releases + activeWork.deployments;
const countedRuntimeResources = runtimeResources.filter((resource) => !isIgnoredRuntimeResource(resource));
const healthyRuntimeCount = countedRuntimeResources.filter(isRuntimeResourceHealthy).length;
const runtimeRate = countedRuntimeResources.length ? Math.round((healthyRuntimeCount / countedRuntimeResources.length) * 100) : null;
const cards = [
{
label: '활성 앱',
value: loading ? '-' : String(activeApps),
delta: `${dashboardApps.length}개 등록`,
hint: '앱 관리 등록 기준',
tone: 'green' as const
},
{
label: '접속 사용자',
value: loading ? '-' : String(activeSessions),
delta: `${sessions.length}개 세션`,
hint: '활성 사용자 세션 기준',
tone: 'green' as const
},
{
label: '진행 중 작업',
value: loading ? '-' : `${activeWorkTotal}`,
delta: `빌드 ${activeWork.builds} · 릴리즈 ${activeWork.releases} · 배포 ${activeWork.deployments}`,
hint: '대기/실행 중 작업 기준',
tone: activeWorkTotal > 0 ? 'amber' as const : 'green' as const
},
{
label: '런타임 정상률',
value: loading || runtimeRate === null ? '-' : `${runtimeRate}%`,
delta: countedRuntimeResources.length ? `${healthyRuntimeCount}/${countedRuntimeResources.length}` : '리소스 없음',
hint: 'Pod/Deployment/StatefulSet 기준',
tone: runtimeRate !== null && runtimeRate < 80 ? 'red' as const : 'green' as const
}
];
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{cards.map((metric) => (
<Card key={metric.label} className="p-5">
{loading ? (
<div>
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<SkeletonBlock className="h-5 w-20 rounded-md" />
</div>
<SkeletonBlock className="mt-5 h-8 w-20" />
<p className="mt-2 text-xs text-muted-foreground">{metric.hint}</p>
</div>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<Badge tone={metric.tone}>{metric.delta}</Badge>
</div>
<div className="mt-4 text-3xl font-semibold tracking-tight">{metric.value}</div>
<p className="mt-1 text-xs text-muted-foreground">{metric.hint}</p>
</>
)}
</Card>
))}
</div>
);
}
function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVersion[]; loading: boolean; onOpen: () => void }) {
const activeVersion = versions.find((version) => version.isActive) ?? versions[0];
return (
<Card className="flex min-h-[520px] min-w-0 flex-col self-stretch overflow-hidden">
<CardHeader
title="런처 버전 관리"
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
action={<DashboardLinkButton onClick={onOpen} label="런처 버전 관리로 이동" />}
/>
<div className="p-5">
{loading ? (
<div className="min-h-48 rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 space-y-2">
<p className="font-medium">Studio Launcher</p>
<SkeletonBlock className="h-3 w-56" />
</div>
<SkeletonBlock className="h-5 w-14 rounded-md" />
</div>
<div className="mt-5 grid gap-3 text-sm">
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">배포 파일</p>
<SkeletonBlock className="mt-3 h-4 w-52" />
</div>
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<SkeletonBlock className="mt-3 h-4 w-full" />
</div>
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<SkeletonBlock className="mt-3 h-4 w-4/5" />
</div>
</div>
</div>
) : activeVersion ? (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">Studio Launcher</p>
<Badge tone={activeVersion.isActive ? 'green' : 'neutral'}>{activeVersion.isActive ? '활성' : '비활성'}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'}
</p>
</div>
</div>
<div className="mt-4 grid gap-3 text-sm">
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">배포 파일</p>
<p className="mt-1 break-all font-medium leading-5">{activeVersion.artifact}</p>
</div>
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<p className="mt-1 break-all font-medium leading-5">{activeVersion.downloadUrl}</p>
</div>
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<p className="mt-1 text-sm leading-6 text-foreground">{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}</p>
</div>
</div>
</div>
) : (
<div className="flex min-h-48 items-center justify-center rounded-md border border-border bg-muted/20 p-4 text-sm text-muted-foreground">
등록된 런처 버전이 없습니다.
</div>
)}
</div>
</Card>
);
}
function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppPipeline[]; loading: boolean; onOpen: () => void }) {
return (
<Card className="flex min-h-[520px] min-w-0 flex-col overflow-hidden">
<CardHeader
title="공통앱"
description="공통앱의 최근 빌드 상태를 확인합니다."
action={<DashboardLinkButton onClick={onOpen} label="공통앱 빌드 관리로 이동" />}
/>
<div className="max-h-[420px] overflow-y-auto">
{loading ? (
<div className="space-y-3 p-5">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-md border border-border bg-muted/20 p-3">
<SkeletonBlock className="h-4 w-48" />
<SkeletonBlock className="mt-3 h-3 w-64" />
</div>
))}
</div>
) : pipelines.length === 0 ? (
<div className="flex min-h-36 items-center justify-center px-5 text-sm text-muted-foreground">표시할 저장소가 없습니다.</div>
) : (
<div className="divide-y divide-border">
{pipelines.slice(0, 8).map((pipeline) => (
<div key={pipeline.id} className="px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-semibold">{pipeline.repository}</p>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">{pipeline.message}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge tone={getTone(pipeline.status)}>{formatPipelineStatus(pipeline.status)}</Badge>
<span>{pipeline.branch}</span>
<span>{formatRelativeTime(pipeline.createdAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
</Card>
);
}
function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow[]; loading: boolean; onMore: () => void }) {
return (
<Card>
<CardHeader
title="사용자/세션"
description="실시간 접근 상태와 세션 제어 항목입니다."
action={
<button
type="button"
{...pageAgentAccessAttrs(false)}
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(event) => {
if (!isTrustedClick(event)) return;
onMore();
}}
title="사용자/세션으로 이동"
aria-label="사용자/세션으로 이동"
>
<ChevronRight className="size-4" />
</button>
}
/>
<div className="divide-y divide-border">
{loading ? (
Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center justify-between gap-3 px-5 py-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center gap-2">
<SkeletonBlock className="h-4 w-28" />
<SkeletonBlock className="h-5 w-12 rounded-md" />
</div>
<SkeletonBlock className="h-3 w-44" />
</div>
<SkeletonBlock className="h-3 w-20 shrink-0" />
</div>
))
) : sessions.length === 0 ? (
<div className="px-5 py-6 text-center text-sm text-muted-foreground">표시할 사용자 세션이 없습니다.</div>
) : null}
{!loading && sessions.map((session) => (
<div key={session.id} className="flex items-center justify-between gap-3 px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{session.user}</p>
<Badge tone={getTone(session.state)}>{session.state}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{session.email} · {session.role}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{formatDateTime(session.lastSeen)}</span>
</div>
))}
</div>
</Card>
);
}
function DashboardLinkButton({ onClick, label, pageAgentAllowed = true }: { onClick: () => void; label: string; pageAgentAllowed?: boolean }) {
return (
<button
type="button"
{...pageAgentAccessAttrs(pageAgentAllowed)}
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(event) => {
if (!pageAgentAllowed && !isTrustedClick(event)) return;
onClick();
}}
title={label}
aria-label={label}
>
<ChevronRight className="size-4" />
</button>
);
}
function AppRegistryTable({
apps: dashboardApps,
releases,
loading,
onOpen
}: {
apps: ApiApp[];
releases: ApiAppRelease[];
loading: boolean;
onOpen: () => void;
}) {
const latestReleaseByApp = getLatestReleaseByApp(releases);
return (
<Card>
<CardHeader
title="앱 관리"
description="등록된 앱 그룹과 구성요소, 최신 릴리즈 상태를 확인합니다."
action={<DashboardLinkButton onClick={onOpen} label="앱 관리로 이동" />}
/>
<div className="max-h-[444px] overflow-auto">
<table className="w-full min-w-[620px] 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>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
Array.from({ length: 5 }).map((_, index) => (
<tr key={index}>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-32" />
<SkeletonBlock className="mt-2 h-3 w-44" />
</td>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-24" />
</td>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-8" />
</td>
<td className="px-5 py-3">
<SkeletonBlock className="h-4 w-8" />
</td>
</tr>
))
) : dashboardApps.length === 0 ? (
<tr>
<td colSpan={4} className="px-5 py-12 text-center text-muted-foreground">등록된 앱이 없습니다.</td>
</tr>
) : null}
{!loading && dashboardApps.map((app) => {
const latestRelease = latestReleaseByApp[app.id];
const workloadCount = app.components?.filter((component) => ['CONTAINER', 'DATABASE', 'MIGRATION'].includes(component.type)).length ?? 0;
return (
<tr key={app.id} className="hover:bg-muted/20">
<td className="px-5 py-3">
<p className="font-medium">{app.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{app.key} · {app.namespace ?? '네임스페이스 없음'}</p>
</td>
<td className="px-5 py-3">
{latestRelease ? (
<div className="flex flex-wrap items-center gap-2">
<Badge tone={getTone(formatJobStatus(latestRelease.status))}>{formatJobStatus(latestRelease.status)}</Badge>
<span className="font-medium text-foreground">{latestRelease.version}</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-5 py-3">{workloadCount}</td>
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
}
function ServerResourcePanel({ metrics, loading }: { metrics: ClusterMetrics | null; loading: boolean }) {
const resources = [
{ label: 'CPU', value: metrics?.summary.cpuPercent ?? null },
{ label: 'Memory', value: metrics?.summary.memoryPercent ?? null },
{ label: 'Storage', value: metrics?.summary.storagePercent ?? null }
];
const nodeCount = metrics?.nodes.length ?? 0;
const nodeNames = metrics?.nodes.map((node) => node.name).join(', ') || '-';
const primaryNode = metrics?.nodes[0] ?? null;
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="서버 리소스"
description={nodeCount ? `k3s 노드 ${nodeCount}개 기준 · ${nodeNames}` : 'k3s 서버 자원 사용률'}
/>
<div className="flex min-h-0 flex-1 flex-col justify-between gap-8 p-5">
<div className="space-y-5">
{resources.map((resource) => (
<div key={resource.label}>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-foreground">{resource.label}</span>
<span className="text-xs text-muted-foreground">{loading ? '-' : resource.value === null ? '-' : `${resource.value}%`}</span>
</div>
{loading ? (
<SkeletonBlock className="h-2 w-full rounded-full" />
) : (
<Progress value={resource.value ?? 0} tone={resource.value !== null && resource.value >= 85 ? 'red' : resource.value !== null && resource.value >= 70 ? 'amber' : 'green'} />
)}
</div>
))}
</div>
<div className="rounded-md border border-border bg-muted/20 px-3 py-3">
<div className="grid gap-2 text-xs">
<ServerResourceDetail label="노드 상태" value={loading ? '-' : primaryNode?.status ?? '-'} />
<ServerResourceDetail label="CPU" value={formatResourceDetail(primaryNode?.cpu)} />
<ServerResourceDetail label="Memory" value={formatResourceDetail(primaryNode?.memory)} />
<ServerResourceDetail label="Storage" value={formatResourceDetail(primaryNode?.storage)} />
</div>
</div>
</div>
</Card>
);
}
function ServerResourceDetail({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 truncate text-right font-medium text-foreground">{value}</span>
</div>
);
}
function formatResourceDetail(metric?: ClusterMetricValue | null) {
if (!metric) return '-';
if (!metric.usage && !metric.capacity) return '-';
return `${metric.usage ?? '-'} / ${metric.capacity ?? '-'}`;
}
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
const visibleResources = resources.filter((resource) => !isIgnoredRuntimeResource(resource));
return (
<Card>
<CardHeader
title="런타임 상태"
description="앱 네임스페이스에서 조회한 주요 리소스 상태입니다."
action={<DashboardLinkButton onClick={onOpen} label="로그/이벤트로 이동" />}
/>
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
{loading ? (
Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center gap-2">
<SkeletonBlock className="h-4 w-44" />
<SkeletonBlock className="h-5 w-16 rounded-md" />
</div>
<SkeletonBlock className="h-3 w-52" />
</div>
<SkeletonBlock className="size-4 shrink-0 rounded-full" />
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div>
<div className="mb-2 flex justify-between">
<SkeletonBlock className="h-3 w-10" />
<SkeletonBlock className="h-3 w-8" />
</div>
<SkeletonBlock className="h-2 w-full rounded-full" />
</div>
<div>
<div className="mb-2 flex justify-between">
<SkeletonBlock className="h-3 w-12" />
<SkeletonBlock className="h-3 w-8" />
</div>
<SkeletonBlock className="h-2 w-full rounded-full" />
</div>
</div>
</div>
))
) : visibleResources.length === 0 ? (
<div className="px-5 py-12 text-center text-sm text-muted-foreground">표시할 런타임 리소스가 없습니다.</div>
) : null}
{!loading && visibleResources.slice(0, 8).map((item) => (
<div key={`${item.namespace}-${item.kind}-${item.name}`} className="px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{item.kind}/{item.name}</p>
<Badge tone={getTone(item.status)}>{item.status}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">{item.appName} · {item.namespace}</p>
</div>
<Activity className="size-4 shrink-0 text-muted-foreground" />
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div>
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
<span>{item.cpu ? 'CPU' : 'Ready'}</span>
<span>{item.cpu || item.ready || '-'}</span>
</div>
<Progress value={isRuntimeResourceHealthy(item) ? 100 : 30} tone={isRuntimeResourceHealthy(item) ? 'green' : 'amber'} />
</div>
<div>
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
<span>{item.memory ? '메모리' : '재시작'}</span>
<span>{item.memory || (item.restarts ?? '-')}</span>
</div>
<Progress value={Math.min((item.restarts ?? 0) * 20, 100)} tone={(item.restarts ?? 0) > 0 ? 'amber' : 'primary'} />
</div>
</div>
</div>
))}
</div>
</Card>
);
}
function NoticePanel({ notices: dashboardNotices, loading, onOpen }: { notices: ApiNotice[]; loading: boolean; onOpen: () => void }) {
return (
<Card>
<CardHeader
title="공지사항"
description="런처 공지와 앱별 공지사항을 관리합니다."
action={<DashboardLinkButton onClick={onOpen} label="공지사항으로 이동" pageAgentAllowed={false} />}
/>
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
{loading ? (
Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center justify-between gap-3 px-5 py-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<SkeletonBlock className="h-5 w-16 rounded-md" />
<SkeletonBlock className="h-5 w-14 rounded-md" />
</div>
<SkeletonBlock className="mt-3 h-4 w-56" />
<SkeletonBlock className="mt-2 h-3 w-72 max-w-full" />
</div>
<SkeletonBlock className="h-3 w-20 shrink-0" />
</div>
))
) : dashboardNotices.length === 0 ? (
<div className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 공지사항이 없습니다.</div>
) : null}
{!loading && dashboardNotices.slice(0, 8).map((notice) => (
<div key={notice.id} className="flex items-center justify-between gap-3 px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge tone={notice.appKey === 'Launcher' ? 'violet' : 'blue'}>{notice.appKey}</Badge>
<Badge tone={notice.isActive ? 'green' : 'neutral'}>{notice.isActive ? '게시' : '비게시'}</Badge>
</div>
<p className="mt-2 truncate text-sm font-medium">{notice.title}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{notice.linkUrl || notice.body}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{formatDateTime(notice.createdAt)}</span>
</div>
))}
</div>
</Card>
);
}
function DashboardPage({
onLauncherVersionsOpen,
onCommonBuildsOpen,
onAppManagementOpen,
onRuntimeLogsOpen,
onNoticesOpen,
onUserSessionsOpen
}: {
onLauncherVersionsOpen: () => void;
onCommonBuildsOpen: () => void;
onAppManagementOpen: () => void;
onRuntimeLogsOpen: () => void;
onNoticesOpen: () => void;
onUserSessionsOpen: () => void;
}) {
const [launcherRows, setLauncherRows] = useState<ApiLauncherVersion[]>([]);
const [dashboardApps, setDashboardApps] = useState<ApiApp[]>([]);
const [dashboardReleases, setDashboardReleases] = useState<ApiAppRelease[]>([]);
const [recentSessions, setRecentSessions] = useState<UserSessionRow[]>([]);
const [activeWorkSummary, setActiveWorkSummary] = useState<ActiveWorkSummary>({ builds: 0, releases: 0, deployments: 0 });
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
const [clusterMetrics, setClusterMetrics] = useState<ClusterMetrics | null>(null);
const [dashboardLoading, setDashboardLoading] = useState(true);
const loadingVisible = useMinimumLoading(dashboardLoading);
useEffect(() => {
let mounted = true;
async function loadDashboard() {
setDashboardLoading(true);
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult, clusterMetricsResult] = await Promise.allSettled([
loadLauncherVersions(),
loadApps(),
loadAppReleases(),
loadUserSessions(),
loadNotices(),
loadCommonAppPipelines(),
loadClusterMetrics()
]);
const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : [];
const nextApps = appsResult.status === 'fulfilled' ? appsResult.value : [];
const nextReleases = releasesResult.status === 'fulfilled' ? releasesResult.value : [];
const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : [];
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
const nextClusterMetrics = clusterMetricsResult.status === 'fulfilled' ? clusterMetricsResult.value : null;
const nextActiveWork = await loadActiveWorkSummary(nextReleases);
const namespaceApps = nextApps.filter((app) => app.namespace);
const [runtimeResults, metricResults] = await Promise.all([
Promise.allSettled(
namespaceApps.map((app) => loadRuntimeNamespaceResources(app.namespace as string).then((result) => ({ app, result })))
),
Promise.allSettled(
namespaceApps.map((app) => loadRuntimePodMetrics(app.namespace as string).then((metrics) => ({ namespace: app.namespace as string, metrics })))
)
]);
const metricByPod = new Map<string, RuntimePodMetric>();
metricResults.forEach((result) => {
if (result.status !== 'fulfilled') return;
result.value.metrics.forEach((metric) => {
metricByPod.set(`${metric.namespace}/${metric.pod}`, metric);
});
});
const nextRuntimeResources = runtimeResults.flatMap((result) => {
if (result.status !== 'fulfilled' || !result.value.result.exists) return [];
return result.value.result.resources.map((resource) => {
const namespace = result.value.result.namespace;
const metric = resource.kind === 'Pod' ? metricByPod.get(`${namespace}/${resource.name}`) : undefined;
return {
...resource,
namespace,
appName: result.value.app.name,
cpu: metric?.cpu,
memory: metric?.memory
};
});
});
if (!mounted) return;
setLauncherRows(nextVersions);
setDashboardApps(nextApps);
setDashboardReleases(nextReleases);
setRecentSessions(nextSessions);
setActiveWorkSummary(nextActiveWork);
setCommonAppPipelines(nextCommonApps);
setDashboardNotices(nextNotices);
setRuntimeResources(nextRuntimeResources);
setClusterMetrics(nextClusterMetrics);
setDashboardLoading(false);
}
loadDashboard().catch(() => {
if (!mounted) return;
setDashboardLoading(false);
});
return () => {
mounted = false;
};
}, []);
return (
<>
<PageTitle
eyebrow="플랫폼 운영"
title="대시보드"
description="플랫폼의 현재 상태와 운영 흐름을 빠르게 파악합니다."
/>
<MetricCards apps={dashboardApps} sessions={recentSessions} activeWork={activeWorkSummary} runtimeResources={runtimeResources} loading={dashboardLoading} />
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} onOpen={onCommonBuildsOpen} />
</div>
<div className="mt-4 grid items-stretch gap-4 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)]">
<AppRegistryTable apps={dashboardApps} releases={dashboardReleases} loading={dashboardLoading} onOpen={onAppManagementOpen} />
<ServerResourcePanel metrics={clusterMetrics} loading={dashboardLoading} />
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<ContainerPanel resources={runtimeResources} loading={dashboardLoading} onOpen={onRuntimeLogsOpen} />
<NoticePanel notices={dashboardNotices} loading={dashboardLoading} onOpen={onNoticesOpen} />
</div>
<LoadingModal open={loadingVisible} />
</>
);
}
function App() {
const location = useLocation();
const navigate = useNavigate();
const activeView = viewFromPath(location.pathname);
const [menuSearch, setMenuSearch] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(() => Boolean(localStorage.getItem('studio-admin-token')));
const [currentAdmin, setCurrentAdmin] = useState<CurrentAdmin | null>(() => readCurrentAdmin());
const currentRole = currentAdmin?.role ?? '';
const isOwner = isOwnerRole(currentRole);
const isViewer = isViewerRole(currentRole);
const changeView = (view: ViewKey) => {
if (view === 'adminManagement' && !isOwner) {
navigate('/dashboard');
return;
}
navigate(pathByView[view] ?? '/dashboard');
};
const logout = () => {
clearAdminSession();
setCurrentAdmin(null);
setIsAuthenticated(false);
navigate('/dashboard', { replace: true });
};
useEffect(() => {
const handleAuthExpired = () => {
setCurrentAdmin(null);
setIsAuthenticated(false);
navigate('/dashboard', { replace: true });
};
window.addEventListener(authExpiredEventName, handleAuthExpired);
return () => window.removeEventListener(authExpiredEventName, handleAuthExpired);
}, [navigate]);
useEffect(() => {
const handleAdminPermissionsChange = () => {
setCurrentAdmin(readCurrentAdmin());
};
window.addEventListener('studio-admin-app-permissions-change', handleAdminPermissionsChange);
return () => window.removeEventListener('studio-admin-app-permissions-change', handleAdminPermissionsChange);
}, []);
useEffect(() => {
if (location.pathname === '/') navigate('/dashboard', { replace: true });
}, [location.pathname, navigate]);
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
fetchCurrentAdmin()
.then((admin) => {
if (cancelled) return;
storeCurrentAdmin(admin);
setCurrentAdmin(admin);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [isAuthenticated]);
useEffect(() => {
if (activeView === 'adminManagement' && !isOwner) {
navigate('/dashboard', { replace: true });
}
}, [activeView, isOwner, navigate]);
const renderView = () => {
switch (activeView) {
case 'commonSources':
return <CommonAppSourcePage />;
case 'commonBuilds':
return <CommonAppBuildPage />;
case 'commonComponents':
return <CommonAppComponentsPage />;
case 'commonLogs':
return <CommonAppLogsPage />;
case 'launcherVersions':
return <LauncherVersionsApiPage />;
case 'launcherComponents':
return <LauncherComponentsPage />;
case 'launcherLogs':
return <LauncherLogsPage />;
case 'launcherSources':
return <LauncherSourcePage />;
case 'launcherBuilds':
return <LauncherBuildPage />;
case 'launcherApps':
return <LauncherAppsApiPage />;
case 'notices':
return <NoticesApiPage />;
case 'terms':
return <TermsApiPage />;
case 'adminManagement':
if (!isOwner) return null;
return <AdminManagementApiPage />;
case 'organizations':
return <OrganizationsApiPage />;
case 'userSessions':
return <UserSessionsApiPage />;
case 'licenses':
return <LicensesApiPage />;
case 'appManagement':
return <AppManagementApiPage />;
case 'appRepositories':
return <RepositoryManagementPage />;
case 'appSources':
return <AppSourcesApiPage />;
case 'appBuilds':
return <BuildManagementPage />;
case 'appReleases':
return <ReleaseManagementPage />;
case 'appDeployments':
return <DeploymentManagementPage />;
case 'appMigrations':
return <MigrationManagementPage />;
case 'gatewayStatus':
return <GatewayStatusRestoredPage />;
case 'gatewayAgents':
return <GatewayAgentsRestoredPage />;
case 'gatewayDevices':
return <GatewayDevicesRestoredPage />;
case 'tailscaleStatus':
return <TailscaleStatusRestoredPage />;
case 'tailscaleRoutes':
return <TailscaleRoutesRestoredPage />;
case 'tailscaleAcl':
return <TailscaleAclRestoredPage />;
case 'infraCluster':
return <InfrastructureClusterPage />;
case 'infraWorkloads':
return <InfrastructureWorkloadsPage />;
case 'infraNetwork':
return <InfrastructureNetworkPage />;
case 'infraStorage':
return <InfrastructureStoragePage />;
case 'monitoring':
return <MonitoringPage />;
case 'agentRegistration':
return <AgentRegistrationRestoredPage />;
case 'agentInstallStatus':
return <AgentInstallStatusRestoredPage />;
case 'agentSshKeys':
return <AgentSshKeysRestoredPage />;
case 'agentWorkspace':
return <AgentWorkspaceRestoredPage />;
case 'agentCommands':
return <AgentCommandsRestoredPage />;
case 'runtimeConfig':
return <RuntimeConfigApiPage />;
case 'runtimeLogs':
return <RuntimeLogsApiPage />;
case 'runtimeDeviceControl':
return <RuntimeDeviceControlPage />;
default:
return (
<DashboardPage
onLauncherVersionsOpen={() => changeView('launcherVersions')}
onCommonBuildsOpen={() => changeView('commonBuilds')}
onAppManagementOpen={() => changeView('appManagement')}
onRuntimeLogsOpen={() => changeView('runtimeLogs')}
onNoticesOpen={() => changeView('notices')}
onUserSessionsOpen={() => changeView('userSessions')}
/>
);
}
};
if (!isAuthenticated) {
return <LoginPage onLogin={(admin) => {
setCurrentAdmin(admin);
setIsAuthenticated(true);
}} />;
}
return (
<div className="min-h-screen overflow-hidden bg-background text-foreground">
<div className="min-h-screen lg:pl-[17rem]">
<Sidebar activeView={activeView} menuSearch={menuSearch} currentAdmin={currentAdmin} onViewChange={changeView} />
<div className="flex h-screen min-w-0 flex-col">
<Topbar
menuSearch={menuSearch}
onMenuSearchChange={setMenuSearch}
currentAdmin={currentAdmin}
onAdminChange={setCurrentAdmin}
onLogout={logout}
/>
<main className={`scrollbar-stable min-h-0 flex-1 overflow-y-auto ${isViewer ? 'viewer-readonly' : ''}`}>
<div className="mx-auto w-full max-w-[1520px] px-6 pb-16 pt-6 md:px-10 md:pb-20 xl:px-14">
{renderView()}
</div>
</main>
</div>
</div>
</div>
);
}
export default App;