1665 lines
64 KiB
TypeScript
1665 lines
64 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import {
|
|
Activity,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CircleHelp,
|
|
ExternalLink,
|
|
LogOut,
|
|
Search,
|
|
SlidersHorizontal
|
|
} from 'lucide-react';
|
|
import { navGroups } from './data/mock';
|
|
import { Badge, Button, Card, CardHeader, Progress } from './components/ui';
|
|
import { formatDateTime as formatDateTimeValue } from './lib/date-format';
|
|
import { apiBaseUrl, apiRequest, authExpiredEventName, clearAdminSession } from './lib/api';
|
|
import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
|
|
import { cn } from './lib/utils';
|
|
import { openPageAgent } from './lib/page-agent';
|
|
import { AppManagementPage as AppManagementApiPage } from './pages/applications/AppManagementPage';
|
|
import { BuildManagementPage } from './pages/applications/BuildManagementPage';
|
|
import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage';
|
|
import { MigrationManagementPage } from './pages/applications/MigrationManagementPage';
|
|
import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPage';
|
|
import { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage';
|
|
import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage';
|
|
import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage';
|
|
import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage';
|
|
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
|
|
import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
|
|
import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage';
|
|
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
|
|
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
|
|
import { RuntimeLogsPage as RuntimeLogsApiPage } from './pages/runtime/RuntimeLogsPage';
|
|
import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage';
|
|
import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage';
|
|
import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage';
|
|
import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
|
|
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
|
|
import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage';
|
|
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
|
|
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
|
|
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
|
import {
|
|
AgentCommandsPage as AgentCommandsRestoredPage,
|
|
AgentInstallStatusPage as AgentInstallStatusRestoredPage,
|
|
AgentRegistrationPage as AgentRegistrationRestoredPage,
|
|
AgentSshKeysPage as AgentSshKeysRestoredPage,
|
|
AgentWorkspacePage as AgentWorkspaceRestoredPage,
|
|
GatewayAgentsPage as GatewayAgentsRestoredPage,
|
|
GatewayDevicesPage as GatewayDevicesRestoredPage,
|
|
GatewayStatusPage as GatewayStatusRestoredPage,
|
|
TailscaleAclPage as TailscaleAclRestoredPage,
|
|
TailscaleRoutesPage as TailscaleRoutesRestoredPage,
|
|
TailscaleStatusPage as TailscaleStatusRestoredPage
|
|
} from './pages/RestoredPages';
|
|
|
|
type ViewKey =
|
|
| 'dashboard'
|
|
| 'commonSources'
|
|
| 'commonBuilds'
|
|
| 'commonComponents'
|
|
| 'launcherSources'
|
|
| 'launcherBuilds'
|
|
| 'launcherVersions'
|
|
| 'launcherComponents'
|
|
| 'launcherApps'
|
|
| 'notices'
|
|
| 'appManagement'
|
|
| 'appRepositories'
|
|
| 'appSources'
|
|
| 'appBuilds'
|
|
| 'appReleases'
|
|
| 'appDeployments'
|
|
| 'appMigrations'
|
|
| 'gatewayStatus'
|
|
| 'gatewayAgents'
|
|
| 'gatewayDevices'
|
|
| 'tailscaleStatus'
|
|
| 'tailscaleRoutes'
|
|
| 'tailscaleAcl'
|
|
| 'agentRegistration'
|
|
| 'agentInstallStatus'
|
|
| 'agentSshKeys'
|
|
| 'agentWorkspace'
|
|
| 'agentCommands'
|
|
| 'runtimeConfig'
|
|
| 'runtimeLogs'
|
|
| 'runtimeDeviceControl'
|
|
| 'adminManagement'
|
|
| 'organizations'
|
|
| 'userSessions'
|
|
| 'licenses';
|
|
|
|
const viewByLabel: Record<string, ViewKey> = {
|
|
대시보드: 'dashboard',
|
|
'버전 관리': 'launcherVersions',
|
|
구성요소: 'launcherComponents',
|
|
'앱 연결': 'launcherApps',
|
|
공지사항: 'notices',
|
|
'앱 관리': 'appManagement',
|
|
'저장소 관리': 'appRepositories',
|
|
'소스 관리': 'appSources',
|
|
'빌드 관리': 'appBuilds',
|
|
'릴리즈 관리': 'appReleases',
|
|
'실행 설정': 'runtimeConfig',
|
|
'배포 관리': 'appDeployments',
|
|
마이그레이션: 'appMigrations',
|
|
'연결 상태': 'gatewayStatus',
|
|
'Agent 관리': 'gatewayAgents',
|
|
'장비 동기화': 'gatewayDevices',
|
|
'노드 현황': 'tailscaleStatus',
|
|
라우트: 'tailscaleRoutes',
|
|
ACL: 'tailscaleAcl',
|
|
'장비 등록': 'agentRegistration',
|
|
'설치 상태': 'agentInstallStatus',
|
|
'SSH 키 배포': 'agentSshKeys',
|
|
'Workspace 설정': 'agentWorkspace',
|
|
'원격 명령': 'agentCommands',
|
|
'로그/이벤트': 'runtimeLogs',
|
|
'장비 제어': 'runtimeDeviceControl',
|
|
'관리자 관리': 'adminManagement',
|
|
'조직 관리': 'organizations',
|
|
'사용자/세션': 'userSessions',
|
|
라이센스: 'licenses'
|
|
};
|
|
|
|
const pathByView: Record<ViewKey, string> = {
|
|
dashboard: '/dashboard',
|
|
commonSources: '/common-apps/sources',
|
|
commonBuilds: '/common-apps/builds',
|
|
commonComponents: '/common-apps/components',
|
|
launcherSources: '/launcher/sources',
|
|
launcherBuilds: '/launcher/builds',
|
|
launcherVersions: '/launcher/versions',
|
|
launcherComponents: '/launcher/components',
|
|
launcherApps: '/launcher/apps',
|
|
notices: '/operations/notices',
|
|
appManagement: '/applications/apps',
|
|
appRepositories: '/applications/repositories',
|
|
appSources: '/applications/sources',
|
|
appBuilds: '/applications/builds',
|
|
appReleases: '/applications/releases',
|
|
appDeployments: '/applications/deployments',
|
|
appMigrations: '/applications/migrations',
|
|
gatewayStatus: '/gateway/status',
|
|
gatewayAgents: '/gateway/agents',
|
|
gatewayDevices: '/gateway/devices',
|
|
tailscaleStatus: '/tailscale/status',
|
|
tailscaleRoutes: '/tailscale/routes',
|
|
tailscaleAcl: '/tailscale/acl',
|
|
agentRegistration: '/agents/registration',
|
|
agentInstallStatus: '/agents/install-status',
|
|
agentSshKeys: '/agents/ssh-keys',
|
|
agentWorkspace: '/agents/workspace',
|
|
agentCommands: '/agents/commands',
|
|
runtimeConfig: '/applications/runtime-settings',
|
|
runtimeLogs: '/runtime/logs',
|
|
runtimeDeviceControl: '/runtime/devices',
|
|
adminManagement: '/operations/admins',
|
|
organizations: '/operations/organizations',
|
|
userSessions: '/operations/users',
|
|
licenses: '/operations/licenses'
|
|
};
|
|
|
|
const viewByPath = Object.fromEntries(
|
|
Object.entries(pathByView).map(([view, path]) => [path, view as ViewKey])
|
|
) as Record<string, ViewKey>;
|
|
|
|
function viewFromPath(pathname: string): ViewKey {
|
|
if (pathname === '/') return 'dashboard';
|
|
return viewByPath[pathname] ?? 'dashboard';
|
|
}
|
|
|
|
type CurrentAdmin = {
|
|
email: string;
|
|
name?: string;
|
|
role?: string;
|
|
};
|
|
|
|
function readCurrentAdmin(): CurrentAdmin | null {
|
|
try {
|
|
const raw = localStorage.getItem('studio-admin');
|
|
if (!raw) return null;
|
|
const admin = JSON.parse(raw) as CurrentAdmin;
|
|
return admin?.email ? admin : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
type ApiAppComponent = {
|
|
id: string;
|
|
appId: string;
|
|
name: string;
|
|
type: string;
|
|
repository?: string | null;
|
|
branch?: string | null;
|
|
buildTarget?: string | null;
|
|
app?: ApiApp;
|
|
};
|
|
|
|
type ApiApp = {
|
|
id: string;
|
|
key: string;
|
|
name: string;
|
|
description?: string | null;
|
|
namespace?: string | null;
|
|
status: string;
|
|
components?: ApiAppComponent[];
|
|
};
|
|
|
|
type ApiLauncherVersion = {
|
|
id: string;
|
|
version: string;
|
|
minimumVersion?: string | null;
|
|
repository?: string | null;
|
|
branch?: string | null;
|
|
commitSha?: string | null;
|
|
artifact: string;
|
|
downloadUrl: string;
|
|
releaseNotes?: string | null;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
type ApiAppRelease = {
|
|
id: string;
|
|
appId: string;
|
|
version: string;
|
|
branch: string;
|
|
artifact?: string | null;
|
|
status: string;
|
|
createdAt: string;
|
|
updatedAt?: string | null;
|
|
app?: ApiApp;
|
|
};
|
|
|
|
type CommonAppPipeline = {
|
|
id: string;
|
|
repository: string;
|
|
message: string;
|
|
branch: string;
|
|
status: string;
|
|
createdAt: number;
|
|
url?: string | null;
|
|
};
|
|
|
|
type ApiNotice = {
|
|
id: string;
|
|
appKey: string;
|
|
title: string;
|
|
body: string;
|
|
linkUrl?: string | null;
|
|
isActive: boolean;
|
|
startsAt?: string | null;
|
|
endsAt?: string | null;
|
|
priority?: number;
|
|
status?: string;
|
|
createdAt: string;
|
|
updatedAt?: string | null;
|
|
};
|
|
|
|
type RuntimeResource = {
|
|
kind: string;
|
|
name: string;
|
|
status: string;
|
|
ready: string;
|
|
restarts: number | null;
|
|
age: string;
|
|
detail: string;
|
|
};
|
|
|
|
type RuntimeResourceResponse = {
|
|
namespace: string;
|
|
exists: boolean;
|
|
summary: {
|
|
pods: number;
|
|
deployments: number;
|
|
statefulSets: number;
|
|
services: number;
|
|
persistentVolumeClaims: number;
|
|
};
|
|
resources: RuntimeResource[];
|
|
};
|
|
|
|
type RuntimePodMetric = {
|
|
namespace: string;
|
|
pod: string;
|
|
cpu: string;
|
|
memory: string;
|
|
};
|
|
|
|
type DashboardJob = {
|
|
id: string;
|
|
status: string;
|
|
};
|
|
|
|
type ActiveWorkSummary = {
|
|
builds: number;
|
|
releases: number;
|
|
deployments: number;
|
|
};
|
|
|
|
type DashboardRuntimeResource = RuntimeResource & {
|
|
namespace: string;
|
|
appName: string;
|
|
cpu?: string;
|
|
memory?: string;
|
|
};
|
|
|
|
type UserSessionRow = {
|
|
id: string;
|
|
no?: number;
|
|
sessionId: string | null;
|
|
userId: string;
|
|
user: string;
|
|
email?: string;
|
|
role: string;
|
|
session: string;
|
|
createdAt?: string | null;
|
|
expiresAt: string | null;
|
|
lastSeen: string | null;
|
|
state: string;
|
|
};
|
|
|
|
function formatDateTime(value?: string | null) {
|
|
return formatDateTimeValue(value);
|
|
}
|
|
|
|
function loadLauncherVersions() {
|
|
return apiRequest<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)}`);
|
|
}
|
|
|
|
const statusTone = {
|
|
success: 'green',
|
|
succeeded: 'green',
|
|
done: 'green',
|
|
running: 'blue',
|
|
started: 'blue',
|
|
pending: 'amber',
|
|
blocked: 'amber',
|
|
created: 'amber',
|
|
failure: 'red',
|
|
failed: 'red',
|
|
error: 'red',
|
|
killed: 'red',
|
|
활성: 'green',
|
|
'실행 중': 'green',
|
|
게시됨: 'green',
|
|
카나리: 'blue',
|
|
예약됨: 'blue',
|
|
점검: 'amber',
|
|
주의: 'amber',
|
|
드레이닝: 'amber',
|
|
준비중: 'amber',
|
|
배포됨: 'green',
|
|
초안: 'neutral',
|
|
대기: 'neutral',
|
|
만료: 'neutral',
|
|
종료: 'neutral',
|
|
'세션 없음': 'neutral',
|
|
비활성: 'neutral',
|
|
'지원 종료': 'red',
|
|
잠김: 'red',
|
|
구성완료: 'green',
|
|
검토필요: 'amber',
|
|
완료: 'green'
|
|
} as const;
|
|
|
|
function getTone(status: string) {
|
|
return statusTone[status as keyof typeof statusTone] ?? 'neutral';
|
|
}
|
|
|
|
function formatRelativeTime(timestamp: number) {
|
|
if (!timestamp) return '-';
|
|
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
|
if (seconds < 60) return '방금 전';
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}분 전`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}시간 전`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}일 전`;
|
|
return new Intl.DateTimeFormat('ko-KR', { month: 'short', day: 'numeric' }).format(new Date(timestamp));
|
|
}
|
|
|
|
type NavGroup = (typeof navGroups)[number];
|
|
type NavItem = NavGroup['items'][number];
|
|
type NavChild = { label: string; view?: string };
|
|
|
|
function resolveNavView(item: NavItem | NavChild): ViewKey | undefined {
|
|
return 'view' in item && item.view ? item.view as ViewKey : viewByLabel[item.label];
|
|
}
|
|
|
|
function isOwnerRole(role?: string | null) {
|
|
return role === 'OWNER';
|
|
}
|
|
|
|
function isViewerRole(role?: string | null) {
|
|
return role === 'VIEWER';
|
|
}
|
|
|
|
function filterNavGroupsByRole(groups: typeof navGroups, role?: string | null) {
|
|
const isOwner = isOwnerRole(role);
|
|
|
|
return groups
|
|
.map((group) => ({
|
|
...group,
|
|
items: group.items.filter((item) => isOwner || item.label !== '관리자 관리')
|
|
}))
|
|
.filter((group) => group.items.length > 0);
|
|
}
|
|
|
|
function pageAgentAccessAttrs(allowed: boolean) {
|
|
return allowed
|
|
? {}
|
|
: {
|
|
'data-page-agent-ignore': 'true',
|
|
'data-page-agent-not-interactive': 'true'
|
|
};
|
|
}
|
|
|
|
function isTrustedClick(event: ReactMouseEvent<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,
|
|
onLogout
|
|
}: {
|
|
menuSearch: string;
|
|
onMenuSearchChange: (value: string) => void;
|
|
currentAdmin: CurrentAdmin | null;
|
|
onLogout: () => void;
|
|
}) {
|
|
const [profileOpen, setProfileOpen] = useState(false);
|
|
const profileRef = useRef<HTMLDivElement | null>(null);
|
|
const accountLabel = currentAdmin?.email ?? '관리자';
|
|
const avatarLetter = accountLabel.trim().charAt(0).toUpperCase() || 'A';
|
|
|
|
useEffect(() => {
|
|
const closeProfile = (event: MouseEvent) => {
|
|
if (!profileRef.current?.contains(event.target as Node)) setProfileOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', closeProfile);
|
|
return () => document.removeEventListener('mousedown', closeProfile);
|
|
}, []);
|
|
|
|
return (
|
|
<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="flex size-6 items-center justify-center rounded bg-muted text-xs font-semibold">
|
|
{avatarLetter}
|
|
</span>
|
|
<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="border-b border-border px-4 py-3">
|
|
<p className="truncate text-sm text-foreground">{currentAdmin?.name || accountLabel}</p>
|
|
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
|
|
</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);
|
|
onLogout();
|
|
}}
|
|
>
|
|
<LogOut className="size-4" />
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
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: { email: string; name: string; role: string } }>;
|
|
})
|
|
.then((data) => {
|
|
localStorage.setItem('studio-admin-token', data.token);
|
|
localStorage.setItem('studio-admin', JSON.stringify(data.admin));
|
|
if (rememberEmail) {
|
|
localStorage.setItem('studio-admin-saved-email', email.trim());
|
|
} else {
|
|
localStorage.removeItem('studio-admin-saved-email');
|
|
}
|
|
setLoginLoading(false);
|
|
onLogin(data.admin);
|
|
})
|
|
.catch(() => {
|
|
setLoginLoading(false);
|
|
setLoginError(true);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<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 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/10',
|
|
'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/25 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 healthyRuntimeCount = runtimeResources.filter(isRuntimeResourceHealthy).length;
|
|
const runtimeRate = runtimeResources.length ? Math.round((healthyRuntimeCount / runtimeResources.length) * 100) : null;
|
|
const cards = [
|
|
{
|
|
label: '활성 앱',
|
|
value: loading ? '-' : String(activeApps),
|
|
delta: `${dashboardApps.length}개 등록`,
|
|
hint: '앱 관리 등록 기준',
|
|
tone: 'green' as const
|
|
},
|
|
{
|
|
label: '접속 사용자',
|
|
value: loading ? '-' : String(activeSessions),
|
|
delta: `${sessions.length}개 세션`,
|
|
hint: '활성 사용자 세션 기준',
|
|
tone: 'green' as const
|
|
},
|
|
{
|
|
label: '진행 중 작업',
|
|
value: loading ? '-' : `${activeWorkTotal}건`,
|
|
delta: `빌드 ${activeWork.builds} · 릴리즈 ${activeWork.releases} · 배포 ${activeWork.deployments}`,
|
|
hint: '대기/실행 중 작업 기준',
|
|
tone: activeWorkTotal > 0 ? 'amber' as const : 'green' as const
|
|
},
|
|
{
|
|
label: '런타임 정상률',
|
|
value: loading || runtimeRate === null ? '-' : `${runtimeRate}%`,
|
|
delta: runtimeResources.length ? `${healthyRuntimeCount}/${runtimeResources.length}` : '리소스 없음',
|
|
hint: 'namespace 리소스 Ready 기준',
|
|
tone: runtimeRate !== null && runtimeRate < 80 ? 'red' as const : 'green' as const
|
|
}
|
|
];
|
|
|
|
return (
|
|
<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="min-w-0 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 }: { pipelines: CommonAppPipeline[]; loading: boolean }) {
|
|
return (
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="공통앱" description="공통앱의 최근 빌드 상태를 확인합니다." />
|
|
<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)}>{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-[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>
|
|
</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-5 w-14 rounded-md" />
|
|
</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-52" />
|
|
</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={6} className="px-5 py-12 text-center text-muted-foreground">등록된 앱이 없습니다.</td>
|
|
</tr>
|
|
) : null}
|
|
{!loading && dashboardApps.map((app) => {
|
|
const latestRelease = latestReleaseByApp[app.id];
|
|
const firstRepository = app.components?.find((component) => component.repository)?.repository;
|
|
const workloadCount = app.components?.filter((component) => ['CONTAINER', 'DATABASE', 'MIGRATION'].includes(component.type)).length ?? 0;
|
|
|
|
return (
|
|
<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">
|
|
<Badge tone={getTone(app.status)}>{app.status}</Badge>
|
|
</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{latestRelease ? `${latestRelease.version} · ${formatJobStatus(latestRelease.status)}` : '-'}</td>
|
|
<td className="px-5 py-3">
|
|
<span className="inline-flex max-w-64 items-center gap-1 truncate text-muted-foreground">
|
|
{firstRepository ?? '-'}
|
|
{firstRepository ? <ExternalLink className="size-3 shrink-0" /> : null}
|
|
</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 ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
|
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>
|
|
))
|
|
) : resources.length === 0 ? (
|
|
<div className="px-5 py-12 text-center text-sm text-muted-foreground">표시할 런타임 리소스가 없습니다.</div>
|
|
) : null}
|
|
{!loading && resources.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,
|
|
onAppManagementOpen,
|
|
onRuntimeLogsOpen,
|
|
onNoticesOpen,
|
|
onUserSessionsOpen
|
|
}: {
|
|
onLauncherVersionsOpen: () => 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 [dashboardLoading, setDashboardLoading] = useState(true);
|
|
const loadingVisible = useMinimumLoading(dashboardLoading);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
async function loadDashboard() {
|
|
setDashboardLoading(true);
|
|
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([
|
|
loadLauncherVersions(),
|
|
loadApps(),
|
|
loadAppReleases(),
|
|
loadUserSessions(),
|
|
loadNotices(),
|
|
loadCommonAppPipelines()
|
|
]);
|
|
|
|
const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : [];
|
|
const nextApps = appsResult.status === 'fulfilled' ? appsResult.value : [];
|
|
const nextReleases = releasesResult.status === 'fulfilled' ? releasesResult.value : [];
|
|
const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
|
const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : [];
|
|
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
|
|
const nextActiveWork = await loadActiveWorkSummary(nextReleases);
|
|
const namespaceApps = nextApps.filter((app) => app.namespace);
|
|
const [runtimeResults, metricResults] = await Promise.all([
|
|
Promise.allSettled(
|
|
namespaceApps.map((app) => loadRuntimeNamespaceResources(app.namespace as string).then((result) => ({ app, result })))
|
|
),
|
|
Promise.allSettled(
|
|
namespaceApps.map((app) => loadRuntimePodMetrics(app.namespace as string).then((metrics) => ({ namespace: app.namespace as string, metrics })))
|
|
)
|
|
]);
|
|
const metricByPod = new Map<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);
|
|
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} />
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<AppRegistryTable apps={dashboardApps} releases={dashboardReleases} loading={dashboardLoading} onOpen={onAppManagementOpen} />
|
|
</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 (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 'launcherVersions':
|
|
return <LauncherVersionsApiPage />;
|
|
case 'launcherComponents':
|
|
return <LauncherComponentsPage />;
|
|
case 'launcherSources':
|
|
return <LauncherSourcePage />;
|
|
case 'launcherBuilds':
|
|
return <LauncherBuildPage />;
|
|
case 'launcherApps':
|
|
return <LauncherAppsApiPage />;
|
|
case 'notices':
|
|
return <NoticesApiPage />;
|
|
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 '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')}
|
|
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}
|
|
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;
|