2411 lines
94 KiB
TypeScript
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;
|