3564 lines
146 KiB
TypeScript
3564 lines
146 KiB
TypeScript
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import {
|
|
Activity,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CircleHelp,
|
|
ExternalLink,
|
|
GitBranch,
|
|
Copy,
|
|
KeyRound,
|
|
Lock,
|
|
LogOut,
|
|
MoreHorizontal,
|
|
Plus,
|
|
Play,
|
|
RotateCcw,
|
|
Search,
|
|
Settings2,
|
|
ShieldCheck,
|
|
SlidersHorizontal,
|
|
Trash2
|
|
} from 'lucide-react';
|
|
import {
|
|
alerts,
|
|
authApiEndpoints,
|
|
appBundles,
|
|
appDeploymentJobs,
|
|
appMigrationJobs,
|
|
appReleasePipelines,
|
|
appSourceConfigs,
|
|
apps,
|
|
containerBlueprints,
|
|
k3sWorkloads,
|
|
agentDevices,
|
|
gatewayConnections,
|
|
launcherCommonSettings,
|
|
launcherVersions,
|
|
licensePlans,
|
|
metrics,
|
|
navGroups,
|
|
notices,
|
|
tailscaleNodes,
|
|
tailscaleSettings,
|
|
runtimeCommands,
|
|
userLicenseAssignments
|
|
} from './data/mock';
|
|
import { Badge, Button, Card, CardHeader, Progress } from './components/ui';
|
|
import { useToast } from './components/toast';
|
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from './components/data-table';
|
|
import { formatDateTime as formatDateTimeValue } from './lib/date-format';
|
|
import { apiFetch, authExpiredEventName, authHeaders, clearAdminSession } from './lib/api';
|
|
import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
|
|
import { cn } from './lib/utils';
|
|
import { getStoredSelectedAppId, setStoredSelectedAppId } from './lib/selected-app';
|
|
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 { 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 { 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 { LauncherCommonSettingsPage as LauncherCommonSettingsApiPage } from './pages/launcher/LauncherCommonSettingsPage';
|
|
import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
|
|
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
|
|
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,
|
|
LauncherCommonSettingsPage as LauncherCommonSettingsRestoredPage,
|
|
TailscaleAclPage as TailscaleAclRestoredPage,
|
|
TailscaleDiagnosticsPage as TailscaleDiagnosticsRestoredPage,
|
|
TailscaleRoutesPage as TailscaleRoutesRestoredPage,
|
|
TailscaleStatusPage as TailscaleStatusRestoredPage
|
|
} from './pages/RestoredPages';
|
|
|
|
type ViewKey =
|
|
| 'dashboard'
|
|
| 'launcherSources'
|
|
| 'launcherBuilds'
|
|
| 'launcherVersions'
|
|
| 'launcherComponents'
|
|
| 'launcherApps'
|
|
| 'launcherCommonSettings'
|
|
| 'notices'
|
|
| 'appManagement'
|
|
| 'appSources'
|
|
| 'appBuilds'
|
|
| 'appReleases'
|
|
| 'appDeployments'
|
|
| 'appMigrations'
|
|
| 'gatewayStatus'
|
|
| 'gatewayAgents'
|
|
| 'gatewayDevices'
|
|
| 'tailscaleStatus'
|
|
| 'tailscaleRoutes'
|
|
| 'tailscaleAcl'
|
|
| 'tailscaleDiagnostics'
|
|
| 'agentRegistration'
|
|
| 'agentInstallStatus'
|
|
| 'agentSshKeys'
|
|
| 'agentWorkspace'
|
|
| 'agentCommands'
|
|
| 'runtimeConfig'
|
|
| 'runtimeLogs'
|
|
| 'runtimeDeviceControl'
|
|
| 'adminManagement'
|
|
| 'userSessions'
|
|
| 'licenses';
|
|
|
|
const viewByLabel: Record<string, ViewKey> = {
|
|
대시보드: 'dashboard',
|
|
'버전 관리': 'launcherVersions',
|
|
구성요소: 'launcherComponents',
|
|
'앱 연결': 'launcherApps',
|
|
'공통 설정': 'launcherCommonSettings',
|
|
공지사항: 'notices',
|
|
'앱 관리': 'appManagement',
|
|
'소스 관리': 'appSources',
|
|
'빌드 관리': 'appBuilds',
|
|
'릴리즈 관리': 'appReleases',
|
|
'실행 설정': 'runtimeConfig',
|
|
'배포 관리': 'appDeployments',
|
|
마이그레이션: 'appMigrations',
|
|
'연결 상태': 'gatewayStatus',
|
|
'Agent 관리': 'gatewayAgents',
|
|
'장비 동기화': 'gatewayDevices',
|
|
'노드 현황': 'tailscaleStatus',
|
|
라우트: 'tailscaleRoutes',
|
|
ACL: 'tailscaleAcl',
|
|
'연결 진단': 'tailscaleDiagnostics',
|
|
'장비 등록': 'agentRegistration',
|
|
'설치 상태': 'agentInstallStatus',
|
|
'SSH 키 배포': 'agentSshKeys',
|
|
'Workspace 설정': 'agentWorkspace',
|
|
'원격 명령': 'agentCommands',
|
|
'로그/이벤트': 'runtimeLogs',
|
|
'장비 제어': 'runtimeDeviceControl',
|
|
'관리자 관리': 'adminManagement',
|
|
'사용자/세션': 'userSessions',
|
|
라이센스: 'licenses'
|
|
};
|
|
|
|
const pathByView: Record<ViewKey, string> = {
|
|
dashboard: '/dashboard',
|
|
launcherSources: '/launcher/sources',
|
|
launcherBuilds: '/launcher/builds',
|
|
launcherVersions: '/launcher/versions',
|
|
launcherComponents: '/launcher/components',
|
|
launcherApps: '/launcher/apps',
|
|
launcherCommonSettings: '/launcher/common-settings',
|
|
notices: '/operations/notices',
|
|
appManagement: '/applications/apps',
|
|
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',
|
|
tailscaleDiagnostics: '/tailscale/diagnostics',
|
|
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',
|
|
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';
|
|
}
|
|
|
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4500/api';
|
|
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
|
const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222';
|
|
|
|
type GiteaRepo = {
|
|
id: number;
|
|
owner: string;
|
|
name: string;
|
|
fullName: string;
|
|
description?: string;
|
|
private: boolean;
|
|
htmlUrl: string;
|
|
cloneUrl: string;
|
|
sshUrl: string;
|
|
defaultBranch: string;
|
|
updatedAt: string;
|
|
language?: string;
|
|
branchCount?: number;
|
|
releaseCount?: number;
|
|
};
|
|
|
|
type 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 LicensePlanSummary = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
assignedCount: number;
|
|
activeUserCount: number;
|
|
expiringSoonCount: number;
|
|
};
|
|
|
|
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 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;
|
|
};
|
|
|
|
type AdminRow = {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER';
|
|
isActive: boolean;
|
|
lastLoginAt: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
sessions?: Array<{
|
|
id: string;
|
|
expiresAt: string | null;
|
|
revokedAt: string | null;
|
|
createdAt: string;
|
|
}>;
|
|
sshKeys?: Array<{
|
|
id: string;
|
|
label?: string | null;
|
|
fingerprint?: string | null;
|
|
}>;
|
|
};
|
|
|
|
const statusTone = {
|
|
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 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 = filterNavGroupsByRole(navGroups, currentAdmin?.role) as typeof navGroups;
|
|
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 flex shrink-0 justify-center px-4 py-3">
|
|
<img className="h-5 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
|
|
</div>
|
|
|
|
<nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
|
|
{visibleGroups.map((group) => (
|
|
<div key={group.label}>
|
|
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
|
<div className="space-y-1">
|
|
{group.items.map((item) => {
|
|
const hasChildren = 'children' in item && item.children;
|
|
const 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"
|
|
onClick={() => {
|
|
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}
|
|
onClick={() => {
|
|
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 LoadingModal({ open, label = '데이터를 불러오는 중입니다.' }: { open: boolean; label?: string }) {
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/45 px-4 backdrop-blur-[2px]" role="status" aria-live="polite">
|
|
<div className="flex min-w-56 flex-col items-center rounded-lg border border-border bg-card px-6 py-5 shadow-xl">
|
|
<div className="size-9 animate-spin rounded-full border-2 border-muted border-t-primary" />
|
|
<p className="mt-3 text-sm font-medium text-foreground">{label}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
|
const [visible, setVisible] = useState(loading);
|
|
const startedAtRef = useRef<number | null>(loading ? Date.now() : null);
|
|
|
|
useEffect(() => {
|
|
if (loading) {
|
|
startedAtRef.current = Date.now();
|
|
setVisible(true);
|
|
return;
|
|
}
|
|
|
|
if (!visible) return;
|
|
|
|
const elapsed = startedAtRef.current ? Date.now() - startedAtRef.current : minimumMs;
|
|
const remaining = Math.max(minimumMs - elapsed, 0);
|
|
const timer = window.setTimeout(() => {
|
|
startedAtRef.current = null;
|
|
setVisible(false);
|
|
}, remaining);
|
|
|
|
return () => window.clearTimeout(timer);
|
|
}, [loading, minimumMs, visible]);
|
|
|
|
return visible;
|
|
}
|
|
|
|
function Topbar({
|
|
menuSearch,
|
|
onMenuSearchChange,
|
|
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">
|
|
<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-5 pt-8">
|
|
<div className="flex justify-center">
|
|
<img className="h-6 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
|
|
</div>
|
|
<div className="mt-7 text-center">
|
|
<h1 className="text-xl font-semibold tracking-tight">관리자 로그인</h1>
|
|
<p className="mt-2 text-sm text-muted-foreground">계정 정보를 입력해 주세요.</p>
|
|
</div>
|
|
</div>
|
|
<form className="space-y-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,
|
|
licensePlans,
|
|
runtimeResources,
|
|
loading
|
|
}: {
|
|
apps: ApiApp[];
|
|
sessions: UserSessionRow[];
|
|
licensePlans: LicensePlanSummary[];
|
|
runtimeResources: DashboardRuntimeResource[];
|
|
loading: boolean;
|
|
}) {
|
|
const activeApps = dashboardApps.filter((app) => isActiveStatus(app.status)).length;
|
|
const activeSessions = sessions.filter((session) => session.state === '활성').length;
|
|
const assignedLicenses = licensePlans.reduce((sum, plan) => sum + plan.assignedCount, 0);
|
|
const activeLicenseUsers = licensePlans.reduce((sum, plan) => sum + plan.activeUserCount, 0);
|
|
const expiringSoonLicenses = licensePlans.reduce((sum, plan) => sum + plan.expiringSoonCount, 0);
|
|
const licenseUsageRate = assignedLicenses ? Math.round((activeLicenseUsers / assignedLicenses) * 100) : null;
|
|
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 || licenseUsageRate === null ? '-' : `${licenseUsageRate}%`,
|
|
delta: assignedLicenses ? `${activeLicenseUsers}/${assignedLicenses}` : '할당 없음',
|
|
hint: expiringSoonLicenses ? `만료 예정 ${expiringSoonLicenses}명` : '활성 라이센스 사용자 기준',
|
|
tone: licenseUsageRate !== null && licenseUsageRate >= 90 ? '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">
|
|
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<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>
|
|
{pipeline.url ? (
|
|
<a href={pipeline.url} target="_blank" rel="noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Woodpecker 저장소 열기">
|
|
<ExternalLink className="size-4" />
|
|
</a>
|
|
) : null}
|
|
</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"
|
|
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
onClick={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 }: { onClick: () => void; label: string }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
onClick={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="공지사항으로 이동" />}
|
|
/>
|
|
<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 LauncherManagementPage() {
|
|
const [launcherRows, setLauncherRows] = useState<ApiLauncherVersion[]>([]);
|
|
const [launcherRepos, setLauncherRepos] = useState<GiteaRepo[]>([]);
|
|
const [launcherLoading, setLauncherLoading] = useState(true);
|
|
const launcherLoadingVisible = useMinimumLoading(launcherLoading);
|
|
const [launcherError, setLauncherError] = useState(false);
|
|
const [sourceModalOpen, setSourceModalOpen] = useState(false);
|
|
const [sourceRepository, setSourceRepository] = useState('');
|
|
const [sourceBranch, setSourceBranch] = useState('release');
|
|
const [sourceCommitSha, setSourceCommitSha] = useState('');
|
|
const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
|
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
|
const [releaseSaveStatus, setReleaseSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
|
const [newLauncherRelease, setNewLauncherRelease] = useState({
|
|
version: '',
|
|
minimumVersion: '',
|
|
artifact: '',
|
|
downloadUrl: '',
|
|
releaseNotes: '',
|
|
repository: '',
|
|
branch: 'release',
|
|
commitSha: ''
|
|
});
|
|
const launcher = launcherRows[0];
|
|
|
|
const refreshLauncherVersions = () => {
|
|
setLauncherLoading(true);
|
|
loadLauncherVersions()
|
|
.then((versions) => {
|
|
setLauncherRows(versions);
|
|
setLauncherError(false);
|
|
setLauncherLoading(false);
|
|
})
|
|
.catch(() => {
|
|
setLauncherRows([]);
|
|
setLauncherError(true);
|
|
setLauncherLoading(false);
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
refreshLauncherVersions();
|
|
loadPlatformRepos()
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error('Gitea repositories request failed');
|
|
return response.json() as Promise<{ repos: GiteaRepo[] }>;
|
|
})
|
|
.then((data) => {
|
|
if (!mounted) return;
|
|
setLauncherRepos(data.repos ?? []);
|
|
})
|
|
.catch(() => {
|
|
if (!mounted) return;
|
|
setLauncherRepos([]);
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const openSourceModal = () => {
|
|
if (!launcher) return;
|
|
|
|
setSourceRepository(launcher.repository ?? launcherRepos[0]?.fullName ?? '');
|
|
setSourceBranch(launcher.branch ?? 'release');
|
|
setSourceCommitSha(launcher.commitSha ?? '');
|
|
setSourceSaveStatus('idle');
|
|
setSourceModalOpen(true);
|
|
};
|
|
|
|
const saveLauncherSource = () => {
|
|
if (!launcher || !sourceRepository.trim()) return;
|
|
|
|
setSourceSaveStatus('saving');
|
|
apiFetch(`${apiBaseUrl}/launcher/versions/${launcher.id}/source`, {
|
|
method: 'PATCH',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
repository: sourceRepository.trim(),
|
|
branch: sourceBranch.trim() || undefined,
|
|
commitSha: sourceCommitSha.trim() || undefined
|
|
})
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error('Launcher source link failed');
|
|
return response.json();
|
|
})
|
|
.then(() => {
|
|
setSourceModalOpen(false);
|
|
setSourceSaveStatus('idle');
|
|
refreshLauncherVersions();
|
|
})
|
|
.catch(() => setSourceSaveStatus('error'));
|
|
};
|
|
|
|
const openReleaseModal = () => {
|
|
setNewLauncherRelease({
|
|
version: '',
|
|
minimumVersion: launcher?.minimumVersion ?? '',
|
|
artifact: '',
|
|
downloadUrl: '',
|
|
releaseNotes: '',
|
|
repository: launcher?.repository ?? launcherRepos[0]?.fullName ?? '',
|
|
branch: launcher?.branch ?? 'release',
|
|
commitSha: ''
|
|
});
|
|
setReleaseSaveStatus('idle');
|
|
setReleaseModalOpen(true);
|
|
};
|
|
|
|
const createLauncherRelease = () => {
|
|
if (!newLauncherRelease.version.trim() || !newLauncherRelease.artifact.trim() || !newLauncherRelease.downloadUrl.trim()) return;
|
|
|
|
setReleaseSaveStatus('saving');
|
|
apiFetch(`${apiBaseUrl}/launcher/versions`, {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
version: newLauncherRelease.version.trim(),
|
|
minimumVersion: newLauncherRelease.minimumVersion.trim() || undefined,
|
|
artifact: newLauncherRelease.artifact.trim(),
|
|
downloadUrl: newLauncherRelease.downloadUrl.trim(),
|
|
releaseNotes: newLauncherRelease.releaseNotes.trim() || undefined,
|
|
repository: newLauncherRelease.repository || undefined,
|
|
branch: newLauncherRelease.branch || undefined,
|
|
commitSha: newLauncherRelease.commitSha.trim() || undefined
|
|
})
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error('Launcher release create failed');
|
|
return response.json();
|
|
})
|
|
.then(() => {
|
|
setReleaseModalOpen(false);
|
|
setReleaseSaveStatus('idle');
|
|
refreshLauncherVersions();
|
|
})
|
|
.catch(() => setReleaseSaveStatus('error'));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="런처 운영"
|
|
title="버전 관리"
|
|
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태만 관리합니다."
|
|
actions={
|
|
<>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={openSourceModal} disabled={!launcher}>
|
|
<Settings2 className="size-4" />
|
|
소스 연결
|
|
</Button>
|
|
<Button className="h-8 px-3 text-[13px]" onClick={openReleaseModal}>
|
|
<Plus className="size-4" />릴리즈 등록
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{launcherError ? (
|
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
|
런처 버전 데이터를 불러오지 못했습니다.
|
|
</div>
|
|
) : null}
|
|
|
|
{!launcherLoading && !launcher ? (
|
|
<Card className="px-5 py-8 text-center text-sm text-muted-foreground">
|
|
등록된 런처 버전이 없습니다.
|
|
</Card>
|
|
) : null}
|
|
|
|
{launcher ? (
|
|
<div className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
|
|
<Card>
|
|
<CardHeader title="메인 런처 배포 상태" description="앱 실행의 진입점인 런처 자체 버전과 배포 파일 상태입니다." />
|
|
<div className="p-5">
|
|
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/20 p-4 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-base font-semibold">Autonomy Studio Launcher</p>
|
|
<Badge tone={launcher.isActive ? 'green' : 'neutral'}>{launcher.isActive ? '활성' : '비활성'}</Badge>
|
|
</div>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
최신 {launcher.version} · 최소 지원 {launcher.minimumVersion || '-'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3">
|
|
<div className="rounded-md border border-border bg-background p-4">
|
|
<p className="text-xs text-muted-foreground">배포 파일</p>
|
|
<p className="mt-2 truncate text-sm font-semibold">{launcher.artifact}</p>
|
|
</div>
|
|
<div className="rounded-md border border-border bg-background p-4">
|
|
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
|
<p className="mt-2 truncate text-sm font-semibold text-muted-foreground">{launcher.downloadUrl}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-md border border-border bg-background p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-xs text-muted-foreground">소스 연결</p>
|
|
<Button variant="ghost" className="h-7 px-2 text-xs" onClick={openSourceModal}>변경</Button>
|
|
</div>
|
|
{launcher.repository ? (
|
|
<div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Repository</p>
|
|
<p className="mt-1 truncate font-semibold">{launcher.repository}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Branch</p>
|
|
<p className="mt-1 truncate font-semibold">{launcher.branch || '-'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Commit</p>
|
|
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{launcher.commitSha || '-'}</p>
|
|
</div>
|
|
<div className="md:col-span-3">
|
|
<p className="text-xs text-muted-foreground">Clone URL</p>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<p className="truncate font-mono text-xs text-muted-foreground">{getHttpCloneUrl(launcher.repository)}</p>
|
|
<button
|
|
type="button"
|
|
title="HTTP clone URL 복사"
|
|
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
onClick={() => copyToClipboard(getHttpCloneUrl(launcher.repository!))}
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="mt-3 text-sm text-muted-foreground">연결된 Gitea 저장소가 없습니다.</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-md border border-border bg-background p-4">
|
|
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
|
|
<p className="mt-2 text-sm leading-6">{launcher.releaseNotes || '-'}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader title="업데이트 기준" description="런처 버전 판정에 필요한 최소 기준입니다." />
|
|
<div className="grid gap-3 p-5">
|
|
{[
|
|
['최신 버전', launcher.version],
|
|
['최소 런처 버전', launcher.minimumVersion || '-']
|
|
].map(([label, value]) => (
|
|
<div key={label} className="flex items-center justify-between gap-4 rounded-md border border-border bg-muted/20 px-4 py-3">
|
|
<span className="text-sm text-muted-foreground">{label}</span>
|
|
<span className="text-right text-sm font-semibold">{value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
) : null}
|
|
|
|
{sourceModalOpen && launcher ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
|
|
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
|
<div className="border-b border-border px-5 py-4">
|
|
<h2 className="text-sm font-semibold">런처 소스 연결</h2>
|
|
</div>
|
|
<div className="space-y-4 px-5 py-5">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Repository</span>
|
|
<select
|
|
value={sourceRepository}
|
|
onChange={(event) => setSourceRepository(event.target.value)}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
>
|
|
{launcherRepos.length === 0 ? <option value="">platform 저장소 없음</option> : null}
|
|
{launcherRepos.map((repo) => (
|
|
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Branch</span>
|
|
<select
|
|
value={sourceBranch}
|
|
onChange={(event) => setSourceBranch(event.target.value)}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
>
|
|
<option value="release">release</option>
|
|
<option value="develop">develop</option>
|
|
<option value="main">main</option>
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
|
<input
|
|
value={sourceCommitSha}
|
|
onChange={(event) => setSourceCommitSha(event.target.value)}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
{sourceSaveStatus === 'error' ? <p className="text-xs text-rose-300">런처 소스 연결 저장에 실패했습니다.</p> : null}
|
|
</div>
|
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
|
|
<Button variant="ghost" onClick={() => setSourceModalOpen(false)}>취소</Button>
|
|
<Button onClick={saveLauncherSource} disabled={!sourceRepository || sourceSaveStatus === 'saving'}>
|
|
{sourceSaveStatus === 'saving' ? '저장 중' : '저장'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{releaseModalOpen ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
|
|
<div className="w-full max-w-2xl rounded-lg border border-border bg-card shadow-xl">
|
|
<div className="border-b border-border px-5 py-4">
|
|
<h2 className="text-sm font-semibold">릴리즈 등록</h2>
|
|
</div>
|
|
<div className="grid max-h-[72vh] gap-4 overflow-y-auto px-5 py-5 md:grid-cols-2">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">버전</span>
|
|
<input
|
|
value={newLauncherRelease.version}
|
|
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, version: event.target.value }))}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">최소 런처 버전</span>
|
|
<input
|
|
value={newLauncherRelease.minimumVersion}
|
|
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, minimumVersion: event.target.value }))}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
<label className="block md:col-span-2">
|
|
<span className="text-xs font-medium text-muted-foreground">배포 파일</span>
|
|
<input
|
|
value={newLauncherRelease.artifact}
|
|
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, artifact: event.target.value }))}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
<label className="block md:col-span-2">
|
|
<span className="text-xs font-medium text-muted-foreground">다운로드 URL</span>
|
|
<input
|
|
value={newLauncherRelease.downloadUrl}
|
|
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, downloadUrl: event.target.value }))}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Repository</span>
|
|
<select
|
|
value={newLauncherRelease.repository}
|
|
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, repository: event.target.value }))}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
>
|
|
<option value="">미연결</option>
|
|
{launcherRepos.map((repo) => (
|
|
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Branch</span>
|
|
<select
|
|
value={newLauncherRelease.branch}
|
|
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, branch: event.target.value }))}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
>
|
|
<option value="release">release</option>
|
|
<option value="develop">develop</option>
|
|
<option value="main">main</option>
|
|
</select>
|
|
</label>
|
|
<label className="block md:col-span-2">
|
|
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
|
<input
|
|
value={newLauncherRelease.commitSha}
|
|
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, commitSha: event.target.value }))}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
<label className="block md:col-span-2">
|
|
<span className="text-xs font-medium text-muted-foreground">릴리즈 노트</span>
|
|
<textarea
|
|
value={newLauncherRelease.releaseNotes}
|
|
onChange={(event) => setNewLauncherRelease((prev) => ({ ...prev, releaseNotes: event.target.value }))}
|
|
className="mt-2 min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
{releaseSaveStatus === 'error' ? (
|
|
<p className="md:col-span-2 text-xs text-rose-300">릴리즈 등록에 실패했습니다. 버전 중복과 필수값을 확인하세요.</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
|
|
<Button variant="ghost" onClick={() => setReleaseModalOpen(false)}>취소</Button>
|
|
<Button
|
|
onClick={createLauncherRelease}
|
|
disabled={
|
|
!newLauncherRelease.version.trim() ||
|
|
!newLauncherRelease.artifact.trim() ||
|
|
!newLauncherRelease.downloadUrl.trim() ||
|
|
releaseSaveStatus === 'saving'
|
|
}
|
|
>
|
|
{releaseSaveStatus === 'saving' ? '등록 중' : '등록'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<LoadingModal open={launcherLoadingVisible} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ContainersPage() {
|
|
const [selectedAppName, setSelectedAppName] = useState(containerBlueprints[0]?.app ?? '');
|
|
const selectedBlueprint = containerBlueprints.find((item) => item.app === selectedAppName) ?? containerBlueprints[0];
|
|
const totalResources = selectedBlueprint?.resources.length ?? 0;
|
|
const secretCount = selectedBlueprint?.resources.reduce((sum, resource) => sum + resource.secrets, 0) ?? 0;
|
|
const envCount = selectedBlueprint?.resources.reduce((sum, resource) => sum + resource.env, 0) ?? 0;
|
|
const runtimeRows = selectedBlueprint
|
|
? k3sWorkloads.filter((workload) => workload.app === selectedBlueprint.app)
|
|
: [];
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="실행 설정"
|
|
description="앱 실행에 필요한 서버 구성, 접속 경로, 설정값, 저장소를 한 묶음으로 관리합니다."
|
|
actions={
|
|
<>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
|
<Settings2 className="size-4" />
|
|
템플릿
|
|
</Button>
|
|
<Button className="h-8 px-3 text-[13px]">
|
|
<Plus className="size-4" />
|
|
리소스 추가
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="space-y-4">
|
|
<Card className="p-5">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold">앱 선택</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">실행 설정을 확인할 앱 그룹을 선택합니다.</p>
|
|
</div>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<select
|
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
|
|
value={selectedAppName}
|
|
onChange={(event) => setSelectedAppName(event.target.value)}
|
|
>
|
|
{containerBlueprints.map((blueprint) => (
|
|
<option key={blueprint.app} value={blueprint.app}>
|
|
{blueprint.app}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{selectedBlueprint ? (
|
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
<span className="rounded-md bg-muted px-2 py-1">{selectedBlueprint.namespace}</span>
|
|
<span className="rounded-md bg-muted px-2 py-1">{selectedBlueprint.release}</span>
|
|
<span className="rounded-md bg-muted px-2 py-1">리소스 {selectedBlueprint.resources.length}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{selectedBlueprint ? (
|
|
<div className="min-w-0 space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
{[
|
|
['네임스페이스', selectedBlueprint.namespace],
|
|
['리소스', `${totalResources}개`],
|
|
['환경변수', `${envCount}개`],
|
|
['시크릿', `${secretCount}개`]
|
|
].map(([label, value]) => (
|
|
<Card key={label} className="p-5">
|
|
<p className="text-sm text-muted-foreground">{label}</p>
|
|
<p className="mt-3 truncate text-2xl font-semibold">{value}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader
|
|
title="리소스 구성"
|
|
description="현재 실행 중인 워크로드와 주요 제어 기능입니다."
|
|
action={
|
|
<span className="text-xs font-semibold text-muted-foreground">
|
|
총 {runtimeRows.length || totalResources}개
|
|
</span>
|
|
}
|
|
/>
|
|
<div className="border-b border-border px-5 py-4">
|
|
<div className="grid gap-3 lg:grid-cols-[220px_180px_minmax(0,1fr)]">
|
|
<label className="space-y-2 text-xs font-medium text-muted-foreground">
|
|
네임스페이스
|
|
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" defaultValue={selectedBlueprint.namespace}>
|
|
<option value={selectedBlueprint.namespace}>{selectedBlueprint.namespace}</option>
|
|
</select>
|
|
</label>
|
|
<label className="space-y-2 text-xs font-medium text-muted-foreground">
|
|
상태
|
|
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" defaultValue="">
|
|
<option value="">전체</option>
|
|
<option value="실행 중">실행 중</option>
|
|
<option value="주의">주의</option>
|
|
</select>
|
|
</label>
|
|
<label className="space-y-2 text-xs font-medium text-muted-foreground">
|
|
검색
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
placeholder="리소스명 / 워크로드 / 노드 / 이미지"
|
|
/>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full min-w-[1180px] text-left text-sm">
|
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="w-12 px-5 py-3 font-medium">상태</th>
|
|
<th className="px-5 py-3 font-medium">리소스</th>
|
|
<th className="px-5 py-3 font-medium">워크로드</th>
|
|
<th className="px-5 py-3 font-medium">준비 상태</th>
|
|
<th className="px-5 py-3 font-medium">재시작</th>
|
|
<th className="px-5 py-3 font-medium">Node</th>
|
|
<th className="px-5 py-3 font-medium">Age</th>
|
|
<th className="px-5 py-3 text-right font-medium">제어</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{(runtimeRows.length ? runtimeRows : selectedBlueprint.resources.map((resource) => ({
|
|
namespace: selectedBlueprint.namespace,
|
|
app: selectedBlueprint.app,
|
|
workload: `${resource.kind}/${resource.name}`,
|
|
pod: resource.name,
|
|
image: resource.image,
|
|
replicas: String(resource.replicas),
|
|
node: '-',
|
|
restarts: 0,
|
|
age: '-',
|
|
status: resource.status
|
|
}))).map((resource) => (
|
|
<tr key={`${resource.namespace}-${resource.pod}`} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3">
|
|
<span
|
|
className={cn(
|
|
'block size-2.5 rounded-full',
|
|
resource.status === '실행 중' || resource.status === '구성완료'
|
|
? 'bg-emerald-400'
|
|
: resource.status === '주의'
|
|
? 'bg-amber-400'
|
|
: 'bg-muted-foreground'
|
|
)}
|
|
/>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<p className="font-semibold">{resource.pod}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{resource.image}</p>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<p className="font-semibold">{resource.workload}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{resource.namespace}</p>
|
|
</td>
|
|
<td className="px-5 py-3">{resource.replicas}</td>
|
|
<td className="px-5 py-3">{resource.restarts}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{resource.node}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{resource.age}</td>
|
|
<td className="px-5 py-3">
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">로그</Button>
|
|
<Button variant="secondary" className="h-8 px-3 text-[13px]">종료</Button>
|
|
<Button variant="ghost" className="size-8 px-0">
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{[
|
|
['Service / Ingress', '외부 노출, 라우팅, TLS 설정'],
|
|
['ConfigMap / Env', '앱별 환경변수와 런타임 설정'],
|
|
['Secret / Storage', '민감정보와 PVC 볼륨 연결']
|
|
].map(([title, description]) => (
|
|
<Card key={title} className="p-5">
|
|
<p className="text-sm font-semibold">{title}</p>
|
|
<p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
|
|
<Button variant="outline" className="mt-4 h-8 px-3 text-[13px]">
|
|
설정
|
|
</Button>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="최근 제어 작업" description="워크로드 재기동, 스케일 변경, 롤백 요청 이력입니다." />
|
|
<div className="divide-y divide-border">
|
|
{[
|
|
['LMS', 'lms-api', '재기동', 'Deployment/lms-api rollout restart', '완료', '2분 전'],
|
|
['LMS', 'lms-adm-web', '로그 조회', '최근 200 lines', '완료', '6분 전'],
|
|
['ADS IDE', 'ads-ide-api', '스케일 변경', 'replicas 1 -> 2', '실행 중', '9분 전'],
|
|
['LMS', 'lms-postgres', '이벤트 조회', 'StatefulSet/lms-postgres', '완료', '18분 전']
|
|
].map(([app, resource, action, detail, status, time], index) => (
|
|
<div key={`${app}-${resource}-${action}-${time}`} className="flex items-center justify-between gap-4 px-5 py-4">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<span className="mt-0.5 w-5 shrink-0 text-sm font-semibold text-muted-foreground">{index + 1}</span>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{action} · {resource}</p>
|
|
<p className="mt-1 truncate text-xs text-muted-foreground">{app} · {detail}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-3">
|
|
<span className="text-xs text-muted-foreground">{time}</span>
|
|
<Badge tone={getTone(status)}>{status}</Badge>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function LauncherAppsPage() {
|
|
const linkedApps = apps
|
|
.filter((app) => app.launcherLinked)
|
|
.sort((a, b) => (a.launcherOrder ?? 999) - (b.launcherOrder ?? 999));
|
|
const unlinkedApps = apps.filter((app) => !app.launcherLinked);
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="런처 운영"
|
|
title="앱 연결"
|
|
description="앱 구성에 등록된 제품 앱 중 런처 홈에 노출하고 실행할 앱을 연결합니다."
|
|
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />앱 연결</Button>}
|
|
/>
|
|
<div className="space-y-4">
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="연결된 앱" description="런처 홈에 표시할 앱과 노출 순서를 관리합니다." />
|
|
<div className="max-h-[520px] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full min-w-[760px] text-left text-sm">
|
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="w-28 px-5 py-3 font-medium">노출순서</th>
|
|
<th className="px-5 py-3 font-medium">앱</th>
|
|
<th className="px-5 py-3 font-medium">릴리즈</th>
|
|
<th className="px-5 py-3 font-medium">실행 경로</th>
|
|
<th className="px-5 py-3 text-right font-medium">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{linkedApps.map((app) => (
|
|
<tr key={app.key} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3 font-semibold">{app.launcherOrder ?? '-'}</td>
|
|
<td className="px-5 py-3 font-semibold">{app.name}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{app.release}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{app.launchUrl}</td>
|
|
<td className="px-5 py-3">
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" className="size-8 px-0" aria-label={`${app.name} 위로 이동`}>
|
|
<ChevronDown className="size-4 rotate-180" />
|
|
</Button>
|
|
<Button variant="outline" className="size-8 px-0" aria-label={`${app.name} 아래로 이동`}>
|
|
<ChevronDown className="size-4" />
|
|
</Button>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]">해제</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="미연결 앱" description="아직 런처에 연결하지 않은 앱입니다." />
|
|
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
|
|
{unlinkedApps.map((app) => (
|
|
<div key={app.key} className="flex items-center justify-between gap-4 px-5 py-4">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{app.name}</p>
|
|
<p className="mt-1 truncate text-xs text-muted-foreground">{app.key} · {app.release}</p>
|
|
</div>
|
|
<Button className="h-8 px-3 text-[13px]">연결</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function GatewayStatusPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 게이트웨이" title="연결 상태" description="Headscale, Agent, 저장소, 런타임 명령 버스의 연결 상태를 확인합니다." />
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{gatewayConnections.map((item) => (
|
|
<Card key={item.name} className="p-5">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="truncate text-sm font-semibold">{item.name}</p>
|
|
<Badge tone={getTone(item.status)}>{item.status}</Badge>
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted-foreground">{item.role}</p>
|
|
<p className="mt-4 truncate text-sm font-medium">{item.endpoint}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">마지막 동기화 {item.lastSync}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function GatewayAgentsPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 게이트웨이" title="Agent 관리" description="플랫폼 게이트웨이가 수집한 Agent 등록 및 상태 정보를 관리합니다." />
|
|
<AgentDeviceTable title="Agent 목록" description="등록된 운영 장비 Agent입니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function GatewayDeviceSyncPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 게이트웨이" title="장비 동기화" description="계정, 장비, 워크스페이스 정보를 인증 서버와 Agent 상태 기준으로 동기화합니다." />
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{[
|
|
['계정/장비 매핑', 'Auth Backend -> Gateway', '활성'],
|
|
['워크스페이스 경로', 'Agent -> Gateway', '활성'],
|
|
['원격 실행 상태', 'Runtime -> Gateway', '주의']
|
|
].map(([title, flow, status]) => (
|
|
<Card key={title} className="p-5">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="text-sm font-semibold">{title}</p>
|
|
<Badge tone={getTone(status)}>{status}</Badge>
|
|
</div>
|
|
<p className="mt-3 text-xs text-muted-foreground">{flow}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AgentDeviceTable({ title, description }: { title: string; description: string }) {
|
|
return (
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title={title} description={description} />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[920px] text-left text-sm">
|
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">장비</th>
|
|
<th className="px-5 py-3 font-medium">계정</th>
|
|
<th className="px-5 py-3 font-medium">Workspace</th>
|
|
<th className="px-5 py-3 font-medium">SSH 키</th>
|
|
<th className="px-5 py-3 font-medium">Last Seen</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{agentDevices.map((device) => (
|
|
<tr key={device.name} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3 font-medium">{device.name}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
|
<td className="px-5 py-3">{device.sshKey}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
|
<td className="px-5 py-3"><Badge tone={getTone(device.status)}>{device.status}</Badge></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function AgentRegistrationPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="장비 등록" description="플랫폼 장비와 계정 정보를 등록하고 Agent 연결을 준비합니다." />
|
|
<AgentDeviceTable title="등록 장비" description="Agent 설치 대상 장비입니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AgentInstallStatusPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="설치 상태" description="Agent 설치, heartbeat, 상태 보고 현황을 확인합니다." />
|
|
<AgentDeviceTable title="설치 상태" description="장비별 Agent 상태입니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AgentSshKeysPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="Agent가 장비에 배포한 SSH 키와 배포 상태를 관리합니다." />
|
|
<AgentDeviceTable title="SSH 키 상태" description="장비별 SSH 키 배포 상태입니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AgentWorkspacePage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="Workspace 설정" description="원격 실행과 저장소 연동에 사용할 장비별 워크스페이스 루트를 관리합니다." />
|
|
<AgentDeviceTable title="Workspace" description="장비별 작업 디렉터리 설정입니다." />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AgentCommandsPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 장비에 전달된 원격 명령과 실행 결과를 관리합니다." />
|
|
<Card>
|
|
<CardHeader title="명령 이력" description="최근 원격 명령 요청입니다." />
|
|
<div className="divide-y divide-border">
|
|
{runtimeCommands.map((item) => (
|
|
<div key={`${item.target}-${item.command}`} className="flex items-center justify-between gap-4 px-5 py-4">
|
|
<div>
|
|
<p className="text-sm font-semibold">{item.command}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{item.target} · {item.requestedBy} · {item.time}</p>
|
|
</div>
|
|
<Badge tone={getTone(item.status)}>{item.status}</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function LauncherCommonSettingsPage() {
|
|
const [apiModalOpen, setApiModalOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="런처 운영"
|
|
title="공통 설정"
|
|
description="런처에서 공통으로 사용하는 연결값과 기본 설정을 관리합니다."
|
|
actions={
|
|
<>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setApiModalOpen(true)}>
|
|
<ExternalLink className="size-4" />
|
|
공통 인증 API 보기
|
|
</Button>
|
|
<Button className="h-8 px-3 text-[13px]">
|
|
<Plus className="size-4" />
|
|
설정 추가
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
{[
|
|
['설정 항목', `${launcherCommonSettings.length}개`],
|
|
['Endpoint', `${launcherCommonSettings.filter((item) => item.type === 'endpoint').length}개`],
|
|
['Database', `${launcherCommonSettings.filter((item) => item.type === 'database').length}개`],
|
|
['활성 상태', `${launcherCommonSettings.filter((item) => item.status === '활성').length}개`]
|
|
].map(([label, value]) => (
|
|
<Card key={label} className="p-5">
|
|
<p className="text-sm text-muted-foreground">{label}</p>
|
|
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="런처 연결 설정" description="여러 기능에서 함께 쓰는 주소, 인증, 저장소 관련 값을 정리합니다." />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[1120px] text-left text-sm">
|
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="w-[280px] px-5 py-3 font-medium">설정</th>
|
|
<th className="px-5 py-3 font-medium">Key</th>
|
|
<th className="px-5 py-3 font-medium">Type</th>
|
|
<th className="px-5 py-3 font-medium">Value</th>
|
|
<th className="px-5 py-3 font-medium"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{launcherCommonSettings.map((setting) => (
|
|
<tr key={setting.key} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3">
|
|
<p className="inline-flex items-center gap-2 font-medium">
|
|
<span
|
|
className={cn(
|
|
'size-2 rounded-full',
|
|
setting.status === '활성' ? 'bg-emerald-400' : setting.status === '주의' ? 'bg-amber-400' : 'bg-muted-foreground'
|
|
)}
|
|
/>
|
|
{setting.name}
|
|
</p>
|
|
</td>
|
|
<td className="px-5 py-3 font-medium">{setting.key}</td>
|
|
<td className="px-5 py-3">{setting.type}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{setting.value}</td>
|
|
<td className="px-5 py-3 text-right">
|
|
<Button variant="ghost" className="size-8 px-0">
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{apiModalOpen ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
|
|
<div className="w-full max-w-[1280px] overflow-hidden rounded-lg border border-border bg-card shadow-2xl">
|
|
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold">공통 인증 API</h2>
|
|
<p className="mt-1 text-xs text-muted-foreground">atnmy-stdo-auth에서 제공하는 런처 및 앱 연동 엔드포인트입니다.</p>
|
|
</div>
|
|
<Button variant="ghost" className="h-8 px-3 text-[13px]" onClick={() => setApiModalOpen(false)}>
|
|
닫기
|
|
</Button>
|
|
</div>
|
|
<div className="max-h-[70vh] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full min-w-[980px] text-left text-sm">
|
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">그룹</th>
|
|
<th className="px-5 py-3 font-medium">Method</th>
|
|
<th className="px-5 py-3 font-medium">Path</th>
|
|
<th className="px-5 py-3 font-medium">용도</th>
|
|
<th className="px-5 py-3 font-medium">권한</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{authApiEndpoints.map((endpoint) => (
|
|
<tr key={`${endpoint.method}-${endpoint.path}`} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3">{endpoint.group}</td>
|
|
<td className="px-5 py-3 font-semibold">{endpoint.method}</td>
|
|
<td className="px-5 py-3 font-medium">{endpoint.path}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{endpoint.purpose}</td>
|
|
<td className="px-5 py-3">
|
|
<Badge tone={endpoint.auth === '없음' ? 'neutral' : endpoint.auth === 'ADMIN' ? 'amber' : 'blue'}>
|
|
{endpoint.auth}
|
|
</Badge>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function loadPlatformRepos() {
|
|
return apiFetch(`${apiBaseUrl}/gitea/repos?owner=platform&limit=8`, { headers: authHeaders() });
|
|
}
|
|
|
|
function loadLauncherVersions() {
|
|
return apiFetch(`${apiBaseUrl}/launcher/versions`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('Launcher versions request failed');
|
|
}
|
|
return response.json() as Promise<ApiLauncherVersion[]>;
|
|
});
|
|
}
|
|
|
|
function loadApps() {
|
|
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('Apps request failed');
|
|
}
|
|
return response.json() as Promise<ApiApp[]>;
|
|
})
|
|
.then(filterAppsByCurrentAdmin);
|
|
}
|
|
|
|
function loadAppReleases() {
|
|
return apiFetch(`${apiBaseUrl}/apps/releases`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('App releases request failed');
|
|
}
|
|
return response.json() as Promise<ApiAppRelease[]>;
|
|
});
|
|
}
|
|
|
|
function loadNotices() {
|
|
return apiFetch(`${apiBaseUrl}/notices`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('Notices request failed');
|
|
}
|
|
return response.json() as Promise<{ notices: ApiNotice[] }>;
|
|
})
|
|
.then((data) => data.notices ?? []);
|
|
}
|
|
|
|
function loadLicensePlans() {
|
|
return apiFetch(`${apiBaseUrl}/licenses/plans`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('License plans request failed');
|
|
}
|
|
return response.json() as Promise<{ plans?: LicensePlanSummary[] }>;
|
|
})
|
|
.then((data) => data.plans ?? []);
|
|
}
|
|
|
|
function loadCommonAppPipelines() {
|
|
return apiFetch(`${apiBaseUrl}/woodpecker/repos`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('Woodpecker repositories request failed');
|
|
}
|
|
return response.json() as Promise<{ repos: CommonAppPipeline[] }>;
|
|
})
|
|
.then((data) => data.repos ?? []);
|
|
}
|
|
|
|
function loadRuntimeNamespaceResources(namespace: string) {
|
|
return apiFetch(`${apiBaseUrl}/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('Runtime resources request failed');
|
|
}
|
|
return response.json() as Promise<RuntimeResourceResponse>;
|
|
});
|
|
}
|
|
|
|
function loadRuntimePodMetrics(namespace?: string) {
|
|
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
|
return apiFetch(`${apiBaseUrl}/runtime/metrics/pods${query}`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('Runtime pod metrics request failed');
|
|
}
|
|
return response.json() as Promise<RuntimePodMetric[]>;
|
|
});
|
|
}
|
|
|
|
function loadAppSources() {
|
|
return apiFetch(`${apiBaseUrl}/apps/sources`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('App sources request failed');
|
|
}
|
|
return response.json() as Promise<ApiAppComponent[]>;
|
|
});
|
|
}
|
|
|
|
function loadUserSessions() {
|
|
return apiFetch(`${apiBaseUrl}/users/sessions`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('User sessions request failed');
|
|
}
|
|
return response.json() as Promise<{ sessions: UserSessionRow[] }>;
|
|
})
|
|
.then((data) => data.sessions ?? []);
|
|
}
|
|
|
|
function loadAdmins() {
|
|
return apiFetch(`${apiBaseUrl}/admins`, { headers: authHeaders() })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('Admins request failed');
|
|
}
|
|
return response.json() as Promise<AdminRow[] | { admins: AdminRow[] }>;
|
|
})
|
|
.then((data) => (Array.isArray(data) ? data : data.admins ?? []));
|
|
}
|
|
|
|
function getBuildTargetForType(type: string) {
|
|
const targets: Record<string, string> = {
|
|
ELECTRON: 'AppImage',
|
|
CONTAINER: 'container-image',
|
|
STATIC_WEB: 'web-image',
|
|
DATABASE: 'database',
|
|
MIGRATION: 'prisma/migrations',
|
|
};
|
|
|
|
return targets[type] ?? 'container-image';
|
|
}
|
|
|
|
function formatDateTime(value: string | null) {
|
|
return formatDateTimeValue(value);
|
|
}
|
|
|
|
function getHttpCloneUrl(repository: string) {
|
|
return `${giteaBaseUrl.replace(/\/$/, '')}/${repository}.git`;
|
|
}
|
|
|
|
function getSshCloneUrl(repository: string) {
|
|
return `${giteaSshBaseUrl.replace(/\/$/, '')}/${repository}.git`;
|
|
}
|
|
|
|
function copyToClipboard(value: string) {
|
|
void navigator.clipboard?.writeText(value);
|
|
}
|
|
|
|
function getBuildArtifact(component: ApiAppComponent, branch: string, commitSha: string) {
|
|
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
|
|
|
|
if (component.type === 'ELECTRON') {
|
|
return `${component.name}-${suffix}.AppImage`;
|
|
}
|
|
|
|
if (component.type === 'DATABASE' || component.type === 'MIGRATION') {
|
|
return `${component.name}:${branch}:migration-check`;
|
|
}
|
|
|
|
return `registry.local/${component.name}:${suffix}`;
|
|
}
|
|
|
|
function getBuildLogLines(job: {
|
|
component: string;
|
|
repository: string;
|
|
branch: string;
|
|
commit: string;
|
|
target: string;
|
|
artifact: string;
|
|
status: string;
|
|
}) {
|
|
return [
|
|
`[00:00] build job initialized: ${job.component}`,
|
|
`[00:01] repository: ${job.repository}`,
|
|
`[00:02] checkout branch: ${job.branch}`,
|
|
`[00:03] checkout commit: ${job.commit}`,
|
|
`[00:08] install dependencies`,
|
|
`[00:25] run build target: ${job.target}`,
|
|
`[01:10] package artifact: ${job.artifact}`,
|
|
`[01:20] status: ${job.status}`
|
|
];
|
|
}
|
|
|
|
function AppBuildsPage() {
|
|
type BuildJobRow = {
|
|
id: string;
|
|
no: number;
|
|
app: string;
|
|
component: string;
|
|
repository: string;
|
|
branch: string;
|
|
commit: string;
|
|
target: string;
|
|
artifact: string;
|
|
requestedBy: string;
|
|
startedAt: string;
|
|
endedAt: string;
|
|
duration: string;
|
|
status: string;
|
|
};
|
|
|
|
const [apiApps, setApiApps] = useState<ApiApp[]>([]);
|
|
const [apiSources, setApiSources] = useState<ApiAppComponent[]>([]);
|
|
const [buildsLoading, setBuildsLoading] = useState(true);
|
|
const buildsLoadingVisible = useMinimumLoading(buildsLoading);
|
|
const [selectedAppId, setSelectedAppId] = useState('');
|
|
const [selectedComponentId, setSelectedComponentId] = useState('');
|
|
const [selectedBranch, setSelectedBranch] = useState('release');
|
|
const [commitSha, setCommitSha] = useState('');
|
|
const [buildRows, setBuildRows] = useState<BuildJobRow[]>([]);
|
|
const [logJob, setLogJob] = useState<BuildJobRow | null>(null);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
setBuildsLoading(true);
|
|
|
|
Promise.all([loadApps(), loadAppSources()])
|
|
.then(([apps, sources]) => {
|
|
if (!mounted) return;
|
|
setApiApps(apps);
|
|
setApiSources(sources);
|
|
setSelectedAppId((current) => current || apps[0]?.id || '');
|
|
const firstSource = sources.find((source) => source.appId === (apps[0]?.id ?? '')) ?? sources[0];
|
|
setSelectedComponentId((current) => current || firstSource?.id || '');
|
|
setSelectedBranch(firstSource?.branch || 'release');
|
|
setBuildsLoading(false);
|
|
})
|
|
.catch(() => {
|
|
if (!mounted) return;
|
|
setApiApps([]);
|
|
setApiSources([]);
|
|
setBuildsLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const selectedApp = apiApps.find((app) => app.id === selectedAppId);
|
|
const componentOptions = apiSources.filter((source) => source.appId === selectedAppId);
|
|
const selectedComponent = apiSources.find((source) => source.id === selectedComponentId) ?? componentOptions[0];
|
|
const selectedTarget = selectedComponent?.buildTarget || (selectedComponent ? getBuildTargetForType(selectedComponent.type) : '-');
|
|
const predictedArtifact = selectedComponent
|
|
? getBuildArtifact(selectedComponent, selectedBranch, commitSha)
|
|
: '-';
|
|
|
|
useEffect(() => {
|
|
if (!selectedAppId) return;
|
|
const firstComponent = apiSources.find((source) => source.appId === selectedAppId);
|
|
setSelectedComponentId(firstComponent?.id || '');
|
|
setSelectedBranch(firstComponent?.branch || 'release');
|
|
}, [apiSources, selectedAppId]);
|
|
|
|
const runBuild = () => {
|
|
if (!selectedApp || !selectedComponent) return;
|
|
|
|
const nextRow: BuildJobRow = {
|
|
id: `local-${Date.now()}`,
|
|
no: buildRows.length + 1,
|
|
app: selectedApp.name,
|
|
component: selectedComponent.name,
|
|
repository: selectedComponent.repository || '-',
|
|
branch: selectedBranch,
|
|
commit: commitSha || '-',
|
|
target: selectedTarget,
|
|
artifact: predictedArtifact,
|
|
requestedBy: 'admin',
|
|
startedAt: '방금',
|
|
endedAt: '-',
|
|
duration: '대기 중',
|
|
status: selectedComponent.repository ? '대기' : '검토필요'
|
|
};
|
|
|
|
setBuildRows((prev) => [nextRow, ...prev.map((row, index) => ({ ...row, no: prev.length - index }))]);
|
|
setLogJob(nextRow);
|
|
};
|
|
|
|
const retryBuild = (job: BuildJobRow) => {
|
|
setBuildRows((prev) => [
|
|
{
|
|
...job,
|
|
id: `retry-${Date.now()}`,
|
|
no: prev.length + 1,
|
|
startedAt: '방금',
|
|
endedAt: '-',
|
|
duration: '대기 중',
|
|
status: '대기'
|
|
},
|
|
...prev.map((row, index) => ({ ...row, no: prev.length - index }))
|
|
]);
|
|
};
|
|
|
|
const buildColumns: Array<DataTableColumn<BuildJobRow>> = [
|
|
{
|
|
key: 'no',
|
|
header: 'No.',
|
|
className: 'w-20 px-5 py-3 font-medium',
|
|
cell: (job) => <span className="font-semibold">{job.no}</span>
|
|
},
|
|
{
|
|
key: 'app',
|
|
header: '앱',
|
|
className: 'w-[180px] px-5 py-3 font-medium',
|
|
cell: (job) => <span className="font-semibold">{job.app}</span>
|
|
},
|
|
{
|
|
key: 'component',
|
|
header: '구성요소',
|
|
cell: (job) => <span>{job.component}</span>
|
|
},
|
|
{
|
|
key: 'repository',
|
|
header: 'Repository',
|
|
cell: (job) => <span className="text-muted-foreground">{job.repository}</span>
|
|
},
|
|
{
|
|
key: 'branch',
|
|
header: 'Branch',
|
|
cell: (job) => <span>{job.branch}</span>
|
|
},
|
|
{
|
|
key: 'commit',
|
|
header: 'Commit',
|
|
cell: (job) => <span className="font-mono text-xs text-muted-foreground">{job.commit}</span>
|
|
},
|
|
{
|
|
key: 'target',
|
|
header: '빌드 대상',
|
|
cell: (job) => <span className="text-muted-foreground">{job.target}</span>
|
|
},
|
|
{
|
|
key: 'artifact',
|
|
header: '결과물',
|
|
cell: (job) => <span className="text-muted-foreground">{job.artifact}</span>
|
|
},
|
|
{
|
|
key: 'requestedBy',
|
|
header: '요청자',
|
|
cell: (job) => <span className="text-muted-foreground">{job.requestedBy}</span>
|
|
},
|
|
{
|
|
key: 'startedAt',
|
|
header: '시작',
|
|
cell: (job) => <span className="text-muted-foreground">{job.startedAt}</span>
|
|
},
|
|
{
|
|
key: 'endedAt',
|
|
header: '종료',
|
|
cell: (job) => <span className="text-muted-foreground">{job.endedAt}</span>
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: '상태',
|
|
cell: (job) => <Badge tone={getTone(job.status)}>{job.status}</Badge>
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
className: 'w-28 px-5 py-3 text-right font-medium',
|
|
cell: (job) => (
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" className="size-8 px-0" title="로그 보기" aria-label="로그 보기" onClick={() => setLogJob(job)}>
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
<Button variant="outline" className="size-8 px-0" title="재실행" aria-label="재실행" onClick={() => retryBuild(job)}>
|
|
<RotateCcw className="size-4" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
];
|
|
const buildFilters: Array<DataTableFilter<BuildJobRow>> = [
|
|
{
|
|
key: 'app',
|
|
label: '앱',
|
|
options: [...new Set(buildRows.map((job) => job.app))].map((app) => ({ label: app, value: app })),
|
|
getValue: (job) => job.app
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: '상태',
|
|
options: [...new Set(buildRows.map((job) => job.status))].map((status) => ({ label: status, value: status })),
|
|
getValue: (job) => job.status
|
|
}
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="빌드 관리"
|
|
description="Gitea 소스에서 컨테이너 이미지와 런처 다운로드용 클라이언트 산출물을 만드는 작업을 관리합니다."
|
|
actions={
|
|
<Button className="h-8 px-3 text-[13px]" onClick={runBuild} disabled={!selectedComponent || buildsLoading}>
|
|
<Play className="size-4" />빌드 실행
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<Card className="mb-4">
|
|
<CardHeader title="빌드 요청" description="앱 그룹, 구성요소, 브랜치, 커밋 기준으로 빌드 작업을 생성합니다." />
|
|
<div className="grid gap-4 p-5 xl:grid-cols-[1.2fr_1fr]">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">앱 그룹</span>
|
|
<select
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={selectedAppId}
|
|
onChange={(event) => setSelectedAppId(event.target.value)}
|
|
>
|
|
{apiApps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
|
|
{apiApps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
|
|
<select
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={selectedComponent?.id ?? ''}
|
|
onChange={(event) => {
|
|
const next = apiSources.find((source) => source.id === event.target.value);
|
|
setSelectedComponentId(event.target.value);
|
|
setSelectedBranch(next?.branch || 'release');
|
|
}}
|
|
>
|
|
{componentOptions.length === 0 ? <option value="">구성요소 없음</option> : null}
|
|
{componentOptions.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Branch</span>
|
|
<select
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={selectedBranch}
|
|
onChange={(event) => setSelectedBranch(event.target.value)}
|
|
>
|
|
<option value="develop">develop</option>
|
|
<option value="release">release</option>
|
|
<option value="main">main</option>
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
value={commitSha}
|
|
onChange={(event) => setCommitSha(event.target.value)}
|
|
placeholder="비워두면 브랜치 최신 커밋 기준"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-4 text-sm">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-muted-foreground">Repository</span>
|
|
<span className="truncate font-semibold">{selectedComponent?.repository || '미연결'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-muted-foreground">빌드 대상</span>
|
|
<span className="truncate font-semibold">{selectedTarget}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-muted-foreground">예상 결과물</span>
|
|
<span className="truncate font-semibold">{predictedArtifact}</span>
|
|
</div>
|
|
<Button className="mt-1 h-8 px-3 text-[13px]" onClick={runBuild} disabled={!selectedComponent || buildsLoading}>
|
|
<Play className="size-4" />빌드 실행
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<DataTableCard
|
|
title="빌드 작업 목록"
|
|
rows={buildRows}
|
|
columns={buildColumns}
|
|
filters={buildFilters}
|
|
searchPlaceholder="앱, 구성요소, 저장소, 결과물 검색"
|
|
getSearchText={(job) => `${job.app} ${job.component} ${job.repository} ${job.branch} ${job.commit} ${job.target} ${job.artifact} ${job.status}`}
|
|
getRowKey={(job) => job.id}
|
|
pageSize={3}
|
|
minWidth={1480}
|
|
/>
|
|
|
|
{logJob ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]" role="dialog" aria-modal="true">
|
|
<div className="w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-xl">
|
|
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4">
|
|
<div>
|
|
<h2 className="text-sm font-semibold">빌드 로그</h2>
|
|
<p className="mt-1 text-xs text-muted-foreground">{logJob.app} · {logJob.component} · {logJob.branch}</p>
|
|
</div>
|
|
<Button variant="ghost" onClick={() => setLogJob(null)}>닫기</Button>
|
|
</div>
|
|
<div className="max-h-[70vh] overflow-auto bg-background p-5 font-mono text-xs leading-6 text-muted-foreground">
|
|
{getBuildLogLines(logJob).map((line) => (
|
|
<p key={line}>{line}</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<LoadingModal open={buildsLoadingVisible} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AppReleasesPage() {
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="릴리즈 관리"
|
|
description="브랜치, 커밋, 빌드 산출물을 묶어 환경별로 배포 가능한 릴리즈를 생성합니다."
|
|
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />릴리즈 생성</Button>}
|
|
/>
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{appReleasePipelines.map((release) => (
|
|
<Card key={`${release.app}-${release.version}`} className="p-5">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="truncate text-sm font-semibold">{release.app}</p>
|
|
<Badge tone={getTone(release.status)}>{release.status}</Badge>
|
|
</div>
|
|
<p className="mt-3 text-2xl font-semibold">{release.version}</p>
|
|
<p className="mt-2 text-xs text-muted-foreground">{`${release.sourceBranch} -> ${release.releaseBranch} · ${release.commit}`}</p>
|
|
<p className="mt-3 text-sm text-muted-foreground">{release.artifacts}</p>
|
|
<p className="mt-3 text-xs text-muted-foreground">{release.createdAt}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AppDeploymentsPage() {
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="배포 관리"
|
|
description="생성된 릴리즈를 개발, 검수, 운영 환경에 배포하고 재배포와 롤백을 관리합니다."
|
|
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />배포 실행</Button>}
|
|
/>
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="배포 작업" description="환경별 배포 상태와 실행자를 확인합니다." />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[980px] text-left text-sm">
|
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">앱</th>
|
|
<th className="px-5 py-3 font-medium">환경</th>
|
|
<th className="px-5 py-3 font-medium">릴리즈</th>
|
|
<th className="px-5 py-3 font-medium">전략</th>
|
|
<th className="px-5 py-3 font-medium">대상</th>
|
|
<th className="px-5 py-3 font-medium">요청자</th>
|
|
<th className="px-5 py-3 font-medium">시각</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{appDeploymentJobs.map((job) => (
|
|
<tr key={`${job.app}-${job.environment}-${job.time}`} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3 font-semibold">{job.app}</td>
|
|
<td className="px-5 py-3">{job.environment}</td>
|
|
<td className="px-5 py-3">{job.release}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{job.strategy}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{job.target}</td>
|
|
<td className="px-5 py-3">{job.requestedBy}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{job.time}</td>
|
|
<td className="px-5 py-3"><Badge tone={getTone(job.status)}>{job.status}</Badge></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AppMigrationsPage() {
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="마이그레이션"
|
|
description="앱 릴리즈에 포함된 DB 마이그레이션 경로와 실행 이력을 관리합니다."
|
|
actions={<Button className="h-8 px-3 text-[13px]"><Plus className="size-4" />마이그레이션 실행</Button>}
|
|
/>
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="마이그레이션 작업" description="브랜치별 DB 변경 적용 상태입니다." />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[980px] text-left text-sm">
|
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">앱</th>
|
|
<th className="px-5 py-3 font-medium">구성요소</th>
|
|
<th className="px-5 py-3 font-medium">Branch</th>
|
|
<th className="px-5 py-3 font-medium">Path</th>
|
|
<th className="px-5 py-3 font-medium">Version</th>
|
|
<th className="px-5 py-3 font-medium">시각</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{appMigrationJobs.map((job) => (
|
|
<tr key={`${job.app}-${job.version}`} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3 font-semibold">{job.app}</td>
|
|
<td className="px-5 py-3">{job.component}</td>
|
|
<td className="px-5 py-3">{job.branch}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{job.path}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{job.version}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{job.time}</td>
|
|
<td className="px-5 py-3"><Badge tone={getTone(job.status)}>{job.status}</Badge></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function RuntimeConfigPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="어플리케이션" title="실행 설정" description="클러스터에서 실행 중인 앱 워크로드와 노드 배치, 재시작 상태를 확인합니다." />
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="워크로드 현황" description="앱 그룹별 실행 리소스와 Pod 상태입니다." />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[1180px] text-left text-sm">
|
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">앱</th>
|
|
<th className="px-5 py-3 font-medium">네임스페이스</th>
|
|
<th className="px-5 py-3 font-medium">워크로드</th>
|
|
<th className="px-5 py-3 font-medium">Pod</th>
|
|
<th className="px-5 py-3 font-medium">Image</th>
|
|
<th className="px-5 py-3 font-medium">Replicas</th>
|
|
<th className="px-5 py-3 font-medium">Node</th>
|
|
<th className="px-5 py-3 font-medium">재시작</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{k3sWorkloads.map((item) => (
|
|
<tr key={`${item.namespace}-${item.pod}`} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3 font-medium">{item.app}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{item.namespace}</td>
|
|
<td className="px-5 py-3">{item.workload}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{item.pod}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{item.image}</td>
|
|
<td className="px-5 py-3">{item.replicas}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{item.node}</td>
|
|
<td className="px-5 py-3">{item.restarts}</td>
|
|
<td className="px-5 py-3"><Badge tone={getTone(item.status)}>{item.status}</Badge></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function RuntimeLogsPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="어플리케이션" title="로그/이벤트" description="실행 중인 워크로드의 로그, 이벤트, 최근 제어 명령 결과를 확인합니다." />
|
|
<Card>
|
|
<CardHeader title="최근 작업" description="로그 조회, 이벤트 조회, 재시작 같은 런타임 제어 요청입니다." />
|
|
<div className="divide-y divide-border">
|
|
{runtimeCommands.map((item) => (
|
|
<div key={`${item.target}-${item.command}`} className="flex items-center justify-between gap-4 px-5 py-4">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{item.command}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{item.target} · {item.requestedBy}</p>
|
|
</div>
|
|
<Badge tone={getTone(item.status)}>{item.status}</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ContainerConfigPage() {
|
|
const resources = containerBlueprints.flatMap((blueprint) => blueprint.resources.map((resource) => ({ app: blueprint.app, ...resource })));
|
|
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 런타임" title="ConfigMap/Secret" description="워크로드별 환경변수와 시크릿 연결 수를 관리합니다." />
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{resources.map((resource) => (
|
|
<Card key={`${resource.app}-${resource.name}`} className="p-5">
|
|
<p className="text-sm font-semibold">{resource.name}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{resource.app}</p>
|
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
|
<div className="rounded-md bg-muted/20 p-3">
|
|
<p className="text-xs text-muted-foreground">Env</p>
|
|
<p className="mt-2 text-xl font-semibold">{resource.env}</p>
|
|
</div>
|
|
<div className="rounded-md bg-muted/20 p-3">
|
|
<p className="text-xs text-muted-foreground">Secrets</p>
|
|
<p className="mt-2 text-xl font-semibold">{resource.secrets}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ContainerStoragePage() {
|
|
const resources = containerBlueprints.flatMap((blueprint) => blueprint.resources.map((resource) => ({ app: blueprint.app, namespace: blueprint.namespace, ...resource })));
|
|
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="플랫폼 런타임" title="스토리지" description="StatefulSet, PVC, 볼륨 마운트가 필요한 리소스를 관리합니다." />
|
|
<Card>
|
|
<CardHeader title="스토리지 리소스" description="PVC가 필요한 리소스만 표시합니다." />
|
|
<div className="divide-y divide-border">
|
|
{resources
|
|
.filter((resource) => resource.storage !== '-')
|
|
.map((resource) => (
|
|
<div key={`${resource.app}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-4">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{resource.name}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{resource.app} · {resource.namespace}</p>
|
|
</div>
|
|
<Badge tone="blue">{resource.storage}</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TailscaleStatusPage() {
|
|
const activeNodes = tailscaleNodes.filter((node) => node.status === '활성').length;
|
|
const routeNodes = tailscaleNodes.filter((node) => node.routes !== '-').length;
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="테일스케일"
|
|
title="노드 현황"
|
|
description="플랫폼 게이트웨이가 수집한 tailnet 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다."
|
|
actions={
|
|
<Button className="h-8 px-3 text-[13px]">
|
|
<Activity className="size-4" />
|
|
상태 갱신
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
{[
|
|
['등록 노드', `${tailscaleNodes.length}대`, '게이트웨이 수집 기준'],
|
|
['온라인', `${activeNodes}대`, '최근 접속 정상'],
|
|
['Subnet Router', `${routeNodes}대`, '클러스터 CIDR 광고']
|
|
].map(([label, value, hint]) => (
|
|
<Card key={label} className="p-5">
|
|
<p className="text-sm text-muted-foreground">{label}</p>
|
|
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="노드 현황" description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다." />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[900px] text-left text-sm">
|
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">노드</th>
|
|
<th className="px-5 py-3 font-medium">IP</th>
|
|
<th className="px-5 py-3 font-medium">역할</th>
|
|
<th className="px-5 py-3 font-medium">사용자</th>
|
|
<th className="px-5 py-3 font-medium">Routes</th>
|
|
<th className="px-5 py-3 font-medium">Last Seen</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{tailscaleNodes.map((node) => (
|
|
<tr key={node.name} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3 font-medium">{node.name}</td>
|
|
<td className="px-5 py-3">{node.ip}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{node.role}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{node.user}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{node.lastSeen}</td>
|
|
<td className="px-5 py-3">
|
|
<Badge tone={getTone(node.status)}>{node.status}</Badge>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TailscaleRoutesPage() {
|
|
const routeNodes = tailscaleNodes.filter((node) => node.routes !== '-');
|
|
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="테일스케일" title="라우트" description="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." />
|
|
<Card>
|
|
<CardHeader title="라우트 광고" description="승인된 subnet route와 노드 상태입니다." />
|
|
<div className="divide-y divide-border">
|
|
{routeNodes.map((node) => (
|
|
<div key={node.name} className="flex items-center justify-between gap-4 px-5 py-4">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{node.routes}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{node.name} · {node.ip}</p>
|
|
</div>
|
|
<Badge tone={getTone(node.status)}>{node.status}</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TailscaleAclPage() {
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="테일스케일" title="ACL" description="사용자 그룹과 장비 태그 기준의 tailnet 접근 정책을 관리합니다." />
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{[
|
|
['강사 장비 접근', 'group:instructors -> tag:teaching-device', '활성'],
|
|
['학생 워크스페이스', 'group:students -> tag:assigned-workspace', '활성'],
|
|
['운영자 전체 접근', 'group:ops -> tag:platform-admin', '활성']
|
|
].map(([name, rule, status]) => (
|
|
<Card key={name} className="p-5">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="text-sm font-semibold">{name}</p>
|
|
<Badge tone={getTone(status)}>{status}</Badge>
|
|
</div>
|
|
<p className="mt-3 text-xs text-muted-foreground">{rule}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TailscaleDiagnosticsPage() {
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="테일스케일"
|
|
title="연결 진단"
|
|
description="Tailnet, subnet route, 플랫폼 게이트웨이 연동 상태를 진단합니다."
|
|
actions={
|
|
<Button className="h-8 px-3 text-[13px]">
|
|
<Activity className="size-4" />
|
|
진단 실행
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="진단 항목" description="런처에는 연결 정보만 제공하고, 실제 세부 설정은 여기에서 관리합니다." />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[880px] text-left text-sm">
|
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">항목</th>
|
|
<th className="px-5 py-3 font-medium">Key</th>
|
|
<th className="px-5 py-3 font-medium">Value</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{tailscaleSettings.map((setting) => (
|
|
<tr key={setting.key} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3">
|
|
<p className="font-medium">{setting.name}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{setting.description}</p>
|
|
</td>
|
|
<td className="px-5 py-3 font-medium">{setting.key}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{setting.value}</td>
|
|
<td className="px-5 py-3">
|
|
<Badge tone={getTone(setting.status)}>{setting.status}</Badge>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</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 [licensePlansSummary, setLicensePlansSummary] = useState<LicensePlanSummary[]>([]);
|
|
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
|
|
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
|
|
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
|
|
const [dashboardLoading, setDashboardLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
async function loadDashboard() {
|
|
setDashboardLoading(true);
|
|
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, licensePlansResult, commonAppsResult] = await Promise.allSettled([
|
|
loadLauncherVersions(),
|
|
loadApps(),
|
|
loadAppReleases(),
|
|
loadUserSessions(),
|
|
loadNotices(),
|
|
loadLicensePlans(),
|
|
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 nextLicensePlans = licensePlansResult.status === 'fulfilled' ? licensePlansResult.value : [];
|
|
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
|
|
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);
|
|
setLicensePlansSummary(nextLicensePlans);
|
|
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} licensePlans={licensePlansSummary} 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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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(() => {
|
|
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 'launcherVersions':
|
|
return <LauncherVersionsApiPage />;
|
|
case 'launcherComponents':
|
|
return <LauncherComponentsPage />;
|
|
case 'launcherSources':
|
|
return <LauncherSourcePage />;
|
|
case 'launcherBuilds':
|
|
return <LauncherBuildPage />;
|
|
case 'launcherApps':
|
|
return <LauncherAppsApiPage />;
|
|
case 'launcherCommonSettings':
|
|
return <LauncherCommonSettingsApiPage />;
|
|
case 'notices':
|
|
return <NoticesApiPage />;
|
|
case 'adminManagement':
|
|
if (!isOwner) return null;
|
|
return <AdminManagementApiPage />;
|
|
case 'userSessions':
|
|
return <UserSessionsApiPage />;
|
|
case 'licenses':
|
|
return <LicensesApiPage />;
|
|
case 'appManagement':
|
|
return <AppManagementApiPage />;
|
|
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 'tailscaleDiagnostics':
|
|
return <TailscaleDiagnosticsRestoredPage />;
|
|
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;
|