Compare commits
32 Commits
917b9b6071
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
| fd18f4e37a | |||
| ecee879694 | |||
| 6383a978a6 | |||
| 964472e148 | |||
| d10cccd1ae | |||
| 44bac0532d | |||
| b7a0afe497 | |||
| b65781fd55 | |||
| d746d9d0dc | |||
| 4a79dab2f6 | |||
| 5a814716b7 | |||
| 4fce1c21a3 | |||
| 37e0ac34c6 | |||
| 616a5e2412 | |||
| 23e3d6e480 | |||
| b3119aba9e | |||
| 0d216e2f03 | |||
| 45d05ba9fb | |||
| 3a46232d4f | |||
| 47dc9730e1 | |||
| d37283093a | |||
| 8ac1047295 | |||
| d897cba32c | |||
| ab460c8ae1 | |||
| b41941d425 | |||
| 145ca008dd | |||
| d87c92ff71 | |||
| 66049c78fd | |||
| ddf3aa9784 | |||
| be6509a4f6 | |||
| e755f245f3 | |||
| 09480540ec |
+49
-42
@@ -13,7 +13,7 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Trash2
|
Trash2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { navGroups } from './data/mock';
|
import { navGroups } from './layout/navigation';
|
||||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from './components/ui';
|
import { Badge, Button, Card, CardHeader, Modal, Progress } from './components/ui';
|
||||||
import { formatDateTime as formatDateTimeValue } from './lib/date-format';
|
import { formatDateTime as formatDateTimeValue } from './lib/date-format';
|
||||||
import { apiBaseUrl, apiFetch, apiRequest, authExpiredEventName, authHeaders, clearAdminSession } from './lib/api';
|
import { apiBaseUrl, apiFetch, apiRequest, authExpiredEventName, authHeaders, clearAdminSession } from './lib/api';
|
||||||
@@ -32,6 +32,8 @@ import { createAdminSshKey } from './pages/operations/operations-api';
|
|||||||
import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage';
|
import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage';
|
||||||
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
|
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
|
||||||
import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
|
import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
|
||||||
|
import { TermsPage as TermsApiPage } from './pages/operations/TermsPage';
|
||||||
|
import { ManualsPage as ManualsApiPage } from './pages/operations/ManualsPage';
|
||||||
import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage';
|
import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage';
|
||||||
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
|
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
|
||||||
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
|
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
|
||||||
@@ -56,13 +58,8 @@ import { MonitoringPage } from './pages/system/MonitoringPage';
|
|||||||
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
AgentCommandsPage as AgentCommandsRestoredPage,
|
AgentCommandsPage as AgentCommandsRestoredPage,
|
||||||
AgentInstallStatusPage as AgentInstallStatusRestoredPage,
|
|
||||||
AgentRegistrationPage as AgentRegistrationRestoredPage,
|
AgentRegistrationPage as AgentRegistrationRestoredPage,
|
||||||
AgentSshKeysPage as AgentSshKeysRestoredPage,
|
AgentSshKeysPage as AgentSshKeysRestoredPage,
|
||||||
AgentWorkspacePage as AgentWorkspaceRestoredPage,
|
|
||||||
GatewayAgentsPage as GatewayAgentsRestoredPage,
|
|
||||||
GatewayDevicesPage as GatewayDevicesRestoredPage,
|
|
||||||
GatewayStatusPage as GatewayStatusRestoredPage,
|
|
||||||
TailscaleAclPage as TailscaleAclRestoredPage,
|
TailscaleAclPage as TailscaleAclRestoredPage,
|
||||||
TailscaleRoutesPage as TailscaleRoutesRestoredPage,
|
TailscaleRoutesPage as TailscaleRoutesRestoredPage,
|
||||||
TailscaleStatusPage as TailscaleStatusRestoredPage
|
TailscaleStatusPage as TailscaleStatusRestoredPage
|
||||||
@@ -81,6 +78,8 @@ type ViewKey =
|
|||||||
| 'launcherLogs'
|
| 'launcherLogs'
|
||||||
| 'launcherApps'
|
| 'launcherApps'
|
||||||
| 'notices'
|
| 'notices'
|
||||||
|
| 'terms'
|
||||||
|
| 'manuals'
|
||||||
| 'appManagement'
|
| 'appManagement'
|
||||||
| 'appRepositories'
|
| 'appRepositories'
|
||||||
| 'appSources'
|
| 'appSources'
|
||||||
@@ -88,9 +87,6 @@ type ViewKey =
|
|||||||
| 'appReleases'
|
| 'appReleases'
|
||||||
| 'appDeployments'
|
| 'appDeployments'
|
||||||
| 'appMigrations'
|
| 'appMigrations'
|
||||||
| 'gatewayStatus'
|
|
||||||
| 'gatewayAgents'
|
|
||||||
| 'gatewayDevices'
|
|
||||||
| 'tailscaleStatus'
|
| 'tailscaleStatus'
|
||||||
| 'tailscaleRoutes'
|
| 'tailscaleRoutes'
|
||||||
| 'tailscaleAcl'
|
| 'tailscaleAcl'
|
||||||
@@ -102,7 +98,6 @@ type ViewKey =
|
|||||||
| 'agentRegistration'
|
| 'agentRegistration'
|
||||||
| 'agentInstallStatus'
|
| 'agentInstallStatus'
|
||||||
| 'agentSshKeys'
|
| 'agentSshKeys'
|
||||||
| 'agentWorkspace'
|
|
||||||
| 'agentCommands'
|
| 'agentCommands'
|
||||||
| 'runtimeConfig'
|
| 'runtimeConfig'
|
||||||
| 'runtimeLogs'
|
| 'runtimeLogs'
|
||||||
@@ -118,6 +113,8 @@ const viewByLabel: Record<string, ViewKey> = {
|
|||||||
구성요소: 'launcherComponents',
|
구성요소: 'launcherComponents',
|
||||||
'앱 연결': 'launcherApps',
|
'앱 연결': 'launcherApps',
|
||||||
공지사항: 'notices',
|
공지사항: 'notices',
|
||||||
|
약관: 'terms',
|
||||||
|
매뉴얼: 'manuals',
|
||||||
'앱 관리': 'appManagement',
|
'앱 관리': 'appManagement',
|
||||||
'저장소 관리': 'appRepositories',
|
'저장소 관리': 'appRepositories',
|
||||||
'소스 관리': 'appSources',
|
'소스 관리': 'appSources',
|
||||||
@@ -126,9 +123,6 @@ const viewByLabel: Record<string, ViewKey> = {
|
|||||||
'실행 설정': 'runtimeConfig',
|
'실행 설정': 'runtimeConfig',
|
||||||
'배포 관리': 'appDeployments',
|
'배포 관리': 'appDeployments',
|
||||||
마이그레이션: 'appMigrations',
|
마이그레이션: 'appMigrations',
|
||||||
'연결 상태': 'gatewayStatus',
|
|
||||||
'Agent 관리': 'gatewayAgents',
|
|
||||||
'장비 동기화': 'gatewayDevices',
|
|
||||||
'노드 현황': 'tailscaleStatus',
|
'노드 현황': 'tailscaleStatus',
|
||||||
라우트: 'tailscaleRoutes',
|
라우트: 'tailscaleRoutes',
|
||||||
ACL: 'tailscaleAcl',
|
ACL: 'tailscaleAcl',
|
||||||
@@ -137,16 +131,14 @@ const viewByLabel: Record<string, ViewKey> = {
|
|||||||
네트워크: 'infraNetwork',
|
네트워크: 'infraNetwork',
|
||||||
스토리지: 'infraStorage',
|
스토리지: 'infraStorage',
|
||||||
모니터링: 'monitoring',
|
모니터링: 'monitoring',
|
||||||
'장비 등록': 'agentRegistration',
|
'플랫폼 등록': 'agentRegistration',
|
||||||
'설치 상태': 'agentInstallStatus',
|
'SSH 키': 'agentSshKeys',
|
||||||
'SSH 키 배포': 'agentSshKeys',
|
|
||||||
'Workspace 설정': 'agentWorkspace',
|
|
||||||
'원격 명령': 'agentCommands',
|
'원격 명령': 'agentCommands',
|
||||||
'로그/이벤트': 'runtimeLogs',
|
'로그/이벤트': 'runtimeLogs',
|
||||||
'장비 제어': 'runtimeDeviceControl',
|
'연결 제어': 'runtimeDeviceControl',
|
||||||
'관리자 관리': 'adminManagement',
|
'관리자 관리': 'adminManagement',
|
||||||
'조직 관리': 'organizations',
|
'조직 관리': 'organizations',
|
||||||
'사용자/세션': 'userSessions',
|
'사용자 관리': 'userSessions',
|
||||||
라이센스: 'licenses'
|
라이센스: 'licenses'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,6 +155,8 @@ const pathByView: Record<ViewKey, string> = {
|
|||||||
launcherLogs: '/launcher/logs',
|
launcherLogs: '/launcher/logs',
|
||||||
launcherApps: '/launcher/apps',
|
launcherApps: '/launcher/apps',
|
||||||
notices: '/operations/notices',
|
notices: '/operations/notices',
|
||||||
|
terms: '/operations/terms',
|
||||||
|
manuals: '/operations/manuals',
|
||||||
appManagement: '/applications/apps',
|
appManagement: '/applications/apps',
|
||||||
appRepositories: '/applications/repositories',
|
appRepositories: '/applications/repositories',
|
||||||
appSources: '/applications/sources',
|
appSources: '/applications/sources',
|
||||||
@@ -170,9 +164,6 @@ const pathByView: Record<ViewKey, string> = {
|
|||||||
appReleases: '/applications/releases',
|
appReleases: '/applications/releases',
|
||||||
appDeployments: '/applications/deployments',
|
appDeployments: '/applications/deployments',
|
||||||
appMigrations: '/applications/migrations',
|
appMigrations: '/applications/migrations',
|
||||||
gatewayStatus: '/gateway/status',
|
|
||||||
gatewayAgents: '/gateway/agents',
|
|
||||||
gatewayDevices: '/gateway/devices',
|
|
||||||
tailscaleStatus: '/tailscale/status',
|
tailscaleStatus: '/tailscale/status',
|
||||||
tailscaleRoutes: '/tailscale/routes',
|
tailscaleRoutes: '/tailscale/routes',
|
||||||
tailscaleAcl: '/tailscale/acl',
|
tailscaleAcl: '/tailscale/acl',
|
||||||
@@ -184,7 +175,6 @@ const pathByView: Record<ViewKey, string> = {
|
|||||||
agentRegistration: '/agents/registration',
|
agentRegistration: '/agents/registration',
|
||||||
agentInstallStatus: '/agents/install-status',
|
agentInstallStatus: '/agents/install-status',
|
||||||
agentSshKeys: '/agents/ssh-keys',
|
agentSshKeys: '/agents/ssh-keys',
|
||||||
agentWorkspace: '/agents/workspace',
|
|
||||||
agentCommands: '/agents/commands',
|
agentCommands: '/agents/commands',
|
||||||
runtimeConfig: '/applications/runtime-settings',
|
runtimeConfig: '/applications/runtime-settings',
|
||||||
runtimeLogs: '/runtime/logs',
|
runtimeLogs: '/runtime/logs',
|
||||||
@@ -586,10 +576,11 @@ function formatRelativeTime(timestamp: number) {
|
|||||||
|
|
||||||
type NavGroup = (typeof navGroups)[number];
|
type NavGroup = (typeof navGroups)[number];
|
||||||
type NavItem = NavGroup['items'][number];
|
type NavItem = NavGroup['items'][number];
|
||||||
type NavChild = { label: string; view?: string };
|
type NavChild = { label: string; path?: string };
|
||||||
|
|
||||||
function resolveNavView(item: NavItem | NavChild): ViewKey | undefined {
|
function resolveNavView(item: NavItem | NavChild): ViewKey | undefined {
|
||||||
return 'view' in item && item.view ? item.view as ViewKey : viewByLabel[item.label];
|
const path = 'path' in item ? item.path : undefined;
|
||||||
|
return path ? viewByPath[path] : viewByLabel[item.label];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOwnerRole(role?: string | null) {
|
function isOwnerRole(role?: string | null) {
|
||||||
@@ -694,9 +685,10 @@ function Sidebar({
|
|||||||
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const hasChildren = 'children' in item && item.children;
|
const children = item.children;
|
||||||
|
const hasChildren = Boolean(children?.length);
|
||||||
const isParentActive = hasChildren
|
const isParentActive = hasChildren
|
||||||
? item.children.some((child) => resolveNavView(child) === activeView)
|
? children!.some((child) => resolveNavView(child) === activeView)
|
||||||
: activeView === resolveNavView(item);
|
: activeView === resolveNavView(item);
|
||||||
const isMenuOpen = Boolean(normalizedMenuSearch) || (openMenus[item.label] ?? isParentActive);
|
const isMenuOpen = Boolean(normalizedMenuSearch) || (openMenus[item.label] ?? isParentActive);
|
||||||
|
|
||||||
@@ -731,7 +723,7 @@ function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
{hasChildren && isMenuOpen ? (
|
{hasChildren && isMenuOpen ? (
|
||||||
<div className="mt-1 space-y-1 pl-6">
|
<div className="mt-1 space-y-1 pl-6">
|
||||||
{item.children.map((child) => {
|
{children!.map((child) => {
|
||||||
const isChildActive = activeView === resolveNavView(child);
|
const isChildActive = activeView === resolveNavView(child);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1725,7 +1717,7 @@ function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="사용자/세션"
|
title="사용자 관리"
|
||||||
description="실시간 접근 상태와 세션 제어 항목입니다."
|
description="실시간 접근 상태와 세션 제어 항목입니다."
|
||||||
action={
|
action={
|
||||||
<button
|
<button
|
||||||
@@ -1736,8 +1728,8 @@ function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow
|
|||||||
if (!isTrustedClick(event)) return;
|
if (!isTrustedClick(event)) return;
|
||||||
onMore();
|
onMore();
|
||||||
}}
|
}}
|
||||||
title="사용자/세션으로 이동"
|
title="사용자 관리로 이동"
|
||||||
aria-label="사용자/세션으로 이동"
|
aria-label="사용자 관리로 이동"
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1940,7 +1932,28 @@ function ServerResourceDetail({ label, value }: { label: string; value: string }
|
|||||||
function formatResourceDetail(metric?: ClusterMetricValue | null) {
|
function formatResourceDetail(metric?: ClusterMetricValue | null) {
|
||||||
if (!metric) return '-';
|
if (!metric) return '-';
|
||||||
if (!metric.usage && !metric.capacity) return '-';
|
if (!metric.usage && !metric.capacity) return '-';
|
||||||
return `${metric.usage ?? '-'} / ${metric.capacity ?? '-'}`;
|
return `${formatBinaryQuantity(metric.usage)} / ${formatBinaryQuantity(metric.capacity)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBinaryQuantity(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const matched = value.match(/^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)$/);
|
||||||
|
if (!matched) return value;
|
||||||
|
|
||||||
|
const numeric = Number(matched[1]);
|
||||||
|
const mi = matched[2] === 'Ki'
|
||||||
|
? numeric / 1024
|
||||||
|
: matched[2] === 'Mi'
|
||||||
|
? numeric
|
||||||
|
: matched[2] === 'Gi'
|
||||||
|
? numeric * 1024
|
||||||
|
: numeric * 1024 * 1024;
|
||||||
|
const format = (amount: number) => Number(amount.toFixed(1)).toLocaleString('ko-KR');
|
||||||
|
|
||||||
|
if (mi >= 1024 * 1024) return `${format(mi / 1024 / 1024)} TiB`;
|
||||||
|
if (mi >= 1024) return `${format(mi / 1024)} GiB`;
|
||||||
|
if (mi >= 1) return `${format(mi)} MiB`;
|
||||||
|
return `${format(mi * 1024)} KiB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
||||||
@@ -2296,6 +2309,10 @@ function App() {
|
|||||||
return <LauncherAppsApiPage />;
|
return <LauncherAppsApiPage />;
|
||||||
case 'notices':
|
case 'notices':
|
||||||
return <NoticesApiPage />;
|
return <NoticesApiPage />;
|
||||||
|
case 'terms':
|
||||||
|
return <TermsApiPage />;
|
||||||
|
case 'manuals':
|
||||||
|
return <ManualsApiPage />;
|
||||||
case 'adminManagement':
|
case 'adminManagement':
|
||||||
if (!isOwner) return null;
|
if (!isOwner) return null;
|
||||||
return <AdminManagementApiPage />;
|
return <AdminManagementApiPage />;
|
||||||
@@ -2319,12 +2336,6 @@ function App() {
|
|||||||
return <DeploymentManagementPage />;
|
return <DeploymentManagementPage />;
|
||||||
case 'appMigrations':
|
case 'appMigrations':
|
||||||
return <MigrationManagementPage />;
|
return <MigrationManagementPage />;
|
||||||
case 'gatewayStatus':
|
|
||||||
return <GatewayStatusRestoredPage />;
|
|
||||||
case 'gatewayAgents':
|
|
||||||
return <GatewayAgentsRestoredPage />;
|
|
||||||
case 'gatewayDevices':
|
|
||||||
return <GatewayDevicesRestoredPage />;
|
|
||||||
case 'tailscaleStatus':
|
case 'tailscaleStatus':
|
||||||
return <TailscaleStatusRestoredPage />;
|
return <TailscaleStatusRestoredPage />;
|
||||||
case 'tailscaleRoutes':
|
case 'tailscaleRoutes':
|
||||||
@@ -2343,12 +2354,8 @@ function App() {
|
|||||||
return <MonitoringPage />;
|
return <MonitoringPage />;
|
||||||
case 'agentRegistration':
|
case 'agentRegistration':
|
||||||
return <AgentRegistrationRestoredPage />;
|
return <AgentRegistrationRestoredPage />;
|
||||||
case 'agentInstallStatus':
|
|
||||||
return <AgentInstallStatusRestoredPage />;
|
|
||||||
case 'agentSshKeys':
|
case 'agentSshKeys':
|
||||||
return <AgentSshKeysRestoredPage />;
|
return <AgentSshKeysRestoredPage />;
|
||||||
case 'agentWorkspace':
|
|
||||||
return <AgentWorkspaceRestoredPage />;
|
|
||||||
case 'agentCommands':
|
case 'agentCommands':
|
||||||
return <AgentCommandsRestoredPage />;
|
return <AgentCommandsRestoredPage />;
|
||||||
case 'runtimeConfig':
|
case 'runtimeConfig':
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ export type DataTableColumn<T> = {
|
|||||||
header: string;
|
header: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
flexible?: boolean;
|
||||||
cell: (row: T) => ReactNode;
|
cell: (row: T) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,7 +100,10 @@ export function DataTableCard<T>({
|
|||||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
const currentPage = Math.min(serverPagination?.page ?? page, totalPages);
|
const currentPage = Math.min(serverPagination?.page ?? page, totalPages);
|
||||||
const pageRows = serverMode ? rows : filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
const pageRows = serverMode ? rows : filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
const flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
const configuredFlexibleColumnIndex = columns.findIndex((column) => column.flexible);
|
||||||
|
const flexibleColumnIndex = configuredFlexibleColumnIndex >= 0
|
||||||
|
? configuredFlexibleColumnIndex
|
||||||
|
: columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
||||||
|
|
||||||
const updateFilter = (key: string, value: string) => {
|
const updateFilter = (key: string, value: string) => {
|
||||||
if (serverPagination?.onFilterChange) {
|
if (serverPagination?.onFilterChange) {
|
||||||
|
|||||||
@@ -1,777 +0,0 @@
|
|||||||
import {
|
|
||||||
MonitorPlay,
|
|
||||||
Bell,
|
|
||||||
Boxes,
|
|
||||||
ChartNoAxesColumnIncreasing,
|
|
||||||
FileKey,
|
|
||||||
HardDrive,
|
|
||||||
KeyRound,
|
|
||||||
LayoutDashboard,
|
|
||||||
Megaphone,
|
|
||||||
MonitorUp,
|
|
||||||
Network,
|
|
||||||
ServerCog,
|
|
||||||
ShieldCheck,
|
|
||||||
Users
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export const navGroups = [
|
|
||||||
{
|
|
||||||
label: '플랫폼',
|
|
||||||
items: [
|
|
||||||
{ label: '대시보드', icon: LayoutDashboard, active: true },
|
|
||||||
{
|
|
||||||
label: '공통앱',
|
|
||||||
icon: ServerCog,
|
|
||||||
children: [
|
|
||||||
{ label: '소스 관리', view: 'commonSources' },
|
|
||||||
{ label: '빌드 관리', view: 'commonBuilds' },
|
|
||||||
{ label: '구성요소', view: 'commonComponents' },
|
|
||||||
{ label: '로그/이벤트', view: 'commonLogs' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '런처',
|
|
||||||
icon: MonitorPlay,
|
|
||||||
children: [
|
|
||||||
{ label: '소스 관리', view: 'launcherSources' },
|
|
||||||
{ label: '빌드 관리', view: 'launcherBuilds' },
|
|
||||||
{ label: '버전 관리' },
|
|
||||||
{ label: '구성요소', view: 'launcherComponents' },
|
|
||||||
{ label: '로그/이벤트', view: 'launcherLogs' },
|
|
||||||
{ label: '앱 연결' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '어플리케이션',
|
|
||||||
icon: Boxes,
|
|
||||||
children: [
|
|
||||||
{ label: '앱 관리' },
|
|
||||||
{ label: '저장소 관리' },
|
|
||||||
{ label: '소스 관리' },
|
|
||||||
{ label: '빌드 관리' },
|
|
||||||
{ label: '릴리즈 관리' },
|
|
||||||
{ label: '실행 설정' },
|
|
||||||
{ label: '배포 관리' },
|
|
||||||
{ label: '마이그레이션' },
|
|
||||||
{ label: '로그/이벤트' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '테일스케일',
|
|
||||||
icon: Network,
|
|
||||||
children: [
|
|
||||||
{ label: '노드 현황' },
|
|
||||||
{ label: '라우트' },
|
|
||||||
{ label: 'ACL' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '에이전트',
|
|
||||||
icon: MonitorUp,
|
|
||||||
children: [
|
|
||||||
{ label: '장비 등록' },
|
|
||||||
{ label: '장비 제어' },
|
|
||||||
{ label: '설치 상태' },
|
|
||||||
{ label: 'SSH 키 배포' },
|
|
||||||
{ label: 'Workspace 설정' },
|
|
||||||
{ label: '원격 명령' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '플랫폼 게이트웨이',
|
|
||||||
icon: ServerCog,
|
|
||||||
children: [
|
|
||||||
{ label: '연결 상태' },
|
|
||||||
{ label: 'Agent 관리' },
|
|
||||||
{ label: '장비 동기화' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '인프라',
|
|
||||||
icon: HardDrive,
|
|
||||||
children: [
|
|
||||||
{ label: '클러스터', view: 'infraCluster' },
|
|
||||||
{ label: '워크로드', view: 'infraWorkloads' },
|
|
||||||
{ label: '네트워크', view: 'infraNetwork' },
|
|
||||||
{ label: '스토리지', view: 'infraStorage' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '운영',
|
|
||||||
items: [
|
|
||||||
{ label: '관리자 관리', icon: ShieldCheck },
|
|
||||||
{ label: '조직 관리', icon: Boxes },
|
|
||||||
{ label: '사용자/세션', icon: Users },
|
|
||||||
{ label: '라이센스', icon: FileKey },
|
|
||||||
{ label: '공지사항', icon: Megaphone }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '시스템',
|
|
||||||
items: [
|
|
||||||
{ label: '모니터링', icon: ChartNoAxesColumnIncreasing },
|
|
||||||
{ label: '정책', icon: ShieldCheck }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const metrics = [
|
|
||||||
{ label: '활성 앱', value: '10', delta: '+0', hint: '런처 등록 앱 10개', tone: 'emerald' },
|
|
||||||
{ label: '접속 사용자', value: '1,284', delta: '+9.8%', hint: '활성 세션 176개', tone: 'sky' },
|
|
||||||
{ label: '라이센스 사용률', value: '72%', delta: '+4%', hint: '468 / 650 시트', tone: 'amber' },
|
|
||||||
{ label: '런타임 정상률', value: '96.4%', delta: '-1.2%', hint: '주의 워크로드 3개', tone: 'rose' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const launcherVersions = [
|
|
||||||
{
|
|
||||||
name: 'Autonomy Studio Launcher',
|
|
||||||
appKey: 'launcher',
|
|
||||||
latestVersion: '1.0.1',
|
|
||||||
minimumSupportedVersion: '1.0.0',
|
|
||||||
status: '활성',
|
|
||||||
artifact: 'autonomy-studio-launcher-1.0.1.deb',
|
|
||||||
downloadUrl: 'https://testfile.autonomystudio.net/media/platform/autonomy-studio-launcher-1.0.1.deb',
|
|
||||||
releaseNotes: '런처 업데이트 확인 및 앱별 설치 상태 판단 기능이 추가되었습니다.'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const authApiEndpoints = [
|
|
||||||
{ group: '상태', method: 'GET', path: '/health', purpose: '서비스 및 Redis 상태 확인', auth: '없음' },
|
|
||||||
{ group: '인증', method: 'POST', path: '/auth/login', purpose: 'access token 발급 및 refresh cookie 설정', auth: '없음' },
|
|
||||||
{ group: '인증', method: 'POST', path: '/auth/refresh', purpose: 'refresh token 회전 및 access token 재발급', auth: '쿠키/토큰' },
|
|
||||||
{ group: '인증', method: 'POST', path: '/auth/logout', purpose: 'refresh token 폐기 및 쿠키 제거', auth: '쿠키/토큰' },
|
|
||||||
{ group: '사용자', method: 'GET', path: '/auth/me', purpose: '현재 사용자, 앱 권한, 라이센스, 소속 조회', auth: 'Bearer' },
|
|
||||||
{ group: '앱 연결', method: 'POST', path: '/auth/handoff', purpose: '런처에서 앱으로 넘길 handoff token 생성', auth: 'Bearer' },
|
|
||||||
{ group: '앱 연결', method: 'POST', path: '/auth/handoff/exchange', purpose: 'handoff token을 앱용 token pair로 교환', auth: '없음' },
|
|
||||||
{ group: '관리자', method: 'POST', path: '/auth/admin/users', purpose: '관리자 사용자 생성, 소속, 라이센스 할당', auth: 'ADMIN' },
|
|
||||||
{ group: '관리자', method: 'GET', path: '/auth/admin/organizations', purpose: '조직 목록 조회', auth: 'ADMIN' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const apps = [
|
|
||||||
{
|
|
||||||
name: 'ADS IDE',
|
|
||||||
key: 'ADS IDE',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.1.0',
|
|
||||||
containers: 2,
|
|
||||||
cpu: 52,
|
|
||||||
memory: 68,
|
|
||||||
gitea: './apps/ads-ide/ads-ide-fe',
|
|
||||||
release: 'ADS-IDE-1.1.0.AppImage',
|
|
||||||
secrets: 8,
|
|
||||||
launcherLinked: true,
|
|
||||||
launcherOrder: 1,
|
|
||||||
launchUrl: 'Ingress 예정 / localhost:5175'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'LMS',
|
|
||||||
key: 'LMS',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.0.0',
|
|
||||||
containers: 3,
|
|
||||||
cpu: 74,
|
|
||||||
memory: 81,
|
|
||||||
gitea: './apps/lms/lms-fe',
|
|
||||||
release: 'LMS-1.0.0.AppImage',
|
|
||||||
secrets: 11,
|
|
||||||
launcherLinked: true,
|
|
||||||
launcherOrder: 2,
|
|
||||||
launchUrl: 'Ingress 예정 / localhost:5176'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Data Management',
|
|
||||||
key: 'Data Management',
|
|
||||||
status: '준비중',
|
|
||||||
version: '1.0.0',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 31,
|
|
||||||
memory: 48,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'data-management',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: false,
|
|
||||||
launcherOrder: null,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FMS',
|
|
||||||
key: 'FMS',
|
|
||||||
status: '활성',
|
|
||||||
version: '2.3.1',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 63,
|
|
||||||
memory: 57,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'fms',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: true,
|
|
||||||
launcherOrder: 3,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ADS Education',
|
|
||||||
key: 'ADS Education',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.0.0',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 18,
|
|
||||||
memory: 22,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'ads-education',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: true,
|
|
||||||
launcherOrder: 4,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Calibration',
|
|
||||||
key: 'Calibration',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.0.4',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 21,
|
|
||||||
memory: 26,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'calibration',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: false,
|
|
||||||
launcherOrder: null,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'HDMAP Builder',
|
|
||||||
key: 'HDMAP Builder',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.0.0',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 15,
|
|
||||||
memory: 19,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'hdmap-builder',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: false,
|
|
||||||
launcherOrder: null,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const sessions = [
|
|
||||||
{ user: 'kim.admin', role: '소유자', location: '서울', device: 'Chrome macOS', state: '활성', lastSeen: '2분 전' },
|
|
||||||
{ user: 'park.ops', role: '운영자', location: '판교', device: 'Edge Windows', state: '활성', lastSeen: '7분 전' },
|
|
||||||
{ user: 'lee.viewer', role: '조회자', location: '부산', device: 'Safari iOS', state: '대기', lastSeen: '31분 전' },
|
|
||||||
{ user: 'choi.dev', role: '개발자', location: '서울', device: 'Firefox Linux', state: '잠김', lastSeen: '1시간 전' },
|
|
||||||
{ user: 'han.pm', role: '관리자', location: '대전', device: 'Chrome Windows', state: '활성', lastSeen: '12분 전' },
|
|
||||||
{ user: 'jung.viewer', role: '조회자', location: '광주', device: 'Safari macOS', state: '대기', lastSeen: '46분 전' },
|
|
||||||
{ user: 'yoon.ops', role: '운영자', location: '서울', device: 'Edge Windows', state: '활성', lastSeen: '5분 전' },
|
|
||||||
{ user: 'seo.admin', role: '관리자', location: '판교', device: 'Chrome Linux', state: '활성', lastSeen: '1분 전' },
|
|
||||||
{ user: 'moon.dev', role: '개발자', location: '부산', device: 'Firefox Linux', state: '잠김', lastSeen: '2시간 전' },
|
|
||||||
{ user: 'lim.user', role: '사용자', location: '인천', device: 'Chrome Android', state: '대기', lastSeen: '28분 전' },
|
|
||||||
{ user: 'oh.instructor', role: '강사', location: '서울', device: 'Safari iPadOS', state: '활성', lastSeen: '9분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const licensePlans = [
|
|
||||||
{
|
|
||||||
code: 'FREE',
|
|
||||||
name: 'Free',
|
|
||||||
description: '기본 무료 라이선스',
|
|
||||||
defaultDurationDays: 365,
|
|
||||||
assigned: 182,
|
|
||||||
activeUsers: 164,
|
|
||||||
expiringSoon: 18,
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'EDU',
|
|
||||||
name: 'Education',
|
|
||||||
description: '교육기관용 라이선스',
|
|
||||||
defaultDurationDays: 365,
|
|
||||||
assigned: 296,
|
|
||||||
activeUsers: 271,
|
|
||||||
expiringSoon: 21,
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'PRO',
|
|
||||||
name: 'Professional',
|
|
||||||
description: '전문가용 라이선스',
|
|
||||||
defaultDurationDays: 365,
|
|
||||||
assigned: 172,
|
|
||||||
activeUsers: 158,
|
|
||||||
expiringSoon: 3,
|
|
||||||
status: '활성'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const userLicenseAssignments = [
|
|
||||||
{ user: 'admin@futuredrive.net', name: 'ATNMY Studio Admin', code: 'PRO', licenseName: 'Professional', startsAt: '06.01.', expiresAt: '무기한', assignedBy: 'system', status: '활성' },
|
|
||||||
{ user: 'teacher@futuredrive.net', name: 'Future Drive Teacher', code: 'EDU', licenseName: 'Education', startsAt: '06.02.', expiresAt: '06.02.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
|
||||||
{ user: 'teacher@yu.ac.kr', name: 'YU Instructor', code: 'EDU', licenseName: 'Education', startsAt: '06.02.', expiresAt: '06.02.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
|
||||||
{ user: 'student01@example.com', name: 'student01', code: 'FREE', licenseName: 'Free', startsAt: '06.05.', expiresAt: '06.05.', assignedBy: 'system', status: '활성' },
|
|
||||||
{ user: 'student03@example.com', name: 'student03', code: 'FREE', licenseName: 'Free', startsAt: '06.05.', expiresAt: '06.05.', assignedBy: 'system', status: '활성' },
|
|
||||||
{ user: 'ops@autonomystudio.local', name: 'ops', code: 'PRO', licenseName: 'Professional', startsAt: '06.10.', expiresAt: '06.10.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
|
||||||
{ user: 'viewer@example.com', name: 'viewer', code: 'FREE', licenseName: 'Free', startsAt: '07.01.', expiresAt: '07.01.', assignedBy: 'system', status: '주의' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appBundles = [
|
|
||||||
{
|
|
||||||
name: 'LMS',
|
|
||||||
key: 'LMS',
|
|
||||||
status: '활성',
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
launcherTarget: 'lms-fe',
|
|
||||||
launcherArtifact: 'LMS-1.0.0.AppImage',
|
|
||||||
ingress: 'https://lms.apps.autonomystudio.local',
|
|
||||||
release: 'lms-1.0.0',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'lms-fe',
|
|
||||||
role: '런처 다운로드 앱',
|
|
||||||
type: 'electron',
|
|
||||||
workload: 'artifact',
|
|
||||||
image: 'LMS-1.0.0.AppImage',
|
|
||||||
replicas: '-',
|
|
||||||
status: '배포됨',
|
|
||||||
repository: './apps/lms/lms-fe'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-be',
|
|
||||||
role: 'API 서버',
|
|
||||||
type: 'deployment',
|
|
||||||
workload: 'Deployment/lms-api',
|
|
||||||
image: 'registry.local/lms-be:1.0.0',
|
|
||||||
replicas: '2/2',
|
|
||||||
status: '실행 중',
|
|
||||||
repository: './apps/lms/lms-be'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-adm-fe',
|
|
||||||
role: '강사용 웹 어드민',
|
|
||||||
type: 'deployment',
|
|
||||||
workload: 'Deployment/lms-adm-web',
|
|
||||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
|
||||||
replicas: '1/1',
|
|
||||||
status: '실행 중',
|
|
||||||
repository: './apps/lms/lms-adm-fe'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-db',
|
|
||||||
role: 'PostgreSQL',
|
|
||||||
type: 'statefulset',
|
|
||||||
workload: 'StatefulSet/lms-postgres',
|
|
||||||
image: 'postgres:16',
|
|
||||||
replicas: '1/1',
|
|
||||||
status: '실행 중',
|
|
||||||
repository: 'PVC/lms-postgres-data'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ADS IDE',
|
|
||||||
key: 'ADS IDE',
|
|
||||||
status: '활성',
|
|
||||||
namespace: 'stdo-ads-ide',
|
|
||||||
launcherTarget: 'ads-ide-fe',
|
|
||||||
launcherArtifact: 'ADS-IDE-1.1.0.AppImage',
|
|
||||||
ingress: 'https://ads-ide.apps.autonomystudio.local',
|
|
||||||
release: 'ads-ide-1.1.0',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'ads-ide-fe',
|
|
||||||
role: '런처 다운로드 앱',
|
|
||||||
type: 'electron',
|
|
||||||
workload: 'artifact',
|
|
||||||
image: 'ADS-IDE-1.1.0.AppImage',
|
|
||||||
replicas: '-',
|
|
||||||
status: '배포됨',
|
|
||||||
repository: './apps/ads-ide/ads-ide-fe'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ads-ide-be',
|
|
||||||
role: 'API 서버',
|
|
||||||
type: 'deployment',
|
|
||||||
workload: 'Deployment/ads-ide-api',
|
|
||||||
image: 'registry.local/ads-ide-be:0.1.0',
|
|
||||||
replicas: '1/1',
|
|
||||||
status: '주의',
|
|
||||||
repository: './apps/ads-ide/ads-ide-be'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Data Management',
|
|
||||||
key: 'Data Management',
|
|
||||||
status: '준비중',
|
|
||||||
namespace: 'stdo-data',
|
|
||||||
launcherTarget: 'data-management-fe',
|
|
||||||
launcherArtifact: '미등록',
|
|
||||||
ingress: '미설정',
|
|
||||||
release: 'data-management-1.0.0',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'data-management-fe',
|
|
||||||
role: '런처 다운로드 앱',
|
|
||||||
type: 'electron',
|
|
||||||
workload: 'artifact',
|
|
||||||
image: '미등록',
|
|
||||||
replicas: '-',
|
|
||||||
status: '준비중',
|
|
||||||
repository: '미연결'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'data-api',
|
|
||||||
role: 'API 서버',
|
|
||||||
type: 'deployment',
|
|
||||||
workload: 'Deployment/data-api',
|
|
||||||
image: 'registry.local/data-api:1.0.0',
|
|
||||||
replicas: '0/1',
|
|
||||||
status: '준비중',
|
|
||||||
repository: '미연결'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appSourceConfigs = [
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
component: 'lms-fe',
|
|
||||||
repository: 'platform/lms-fe',
|
|
||||||
branch: 'release',
|
|
||||||
commit: '8d41c2a',
|
|
||||||
sourceType: 'electron',
|
|
||||||
target: 'LMS-1.0.0.AppImage',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
component: 'lms-be',
|
|
||||||
repository: 'platform/lms-be',
|
|
||||||
branch: 'release',
|
|
||||||
commit: '3ac92ef',
|
|
||||||
sourceType: 'container',
|
|
||||||
target: 'registry.local/lms-be:1.0.0',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
component: 'lms-adm-fe',
|
|
||||||
repository: 'platform/lms-adm-fe',
|
|
||||||
branch: 'release',
|
|
||||||
commit: 'f17ab31',
|
|
||||||
sourceType: 'static-web',
|
|
||||||
target: 'registry.local/lms-adm-fe:0.1.0',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
component: 'lms-db',
|
|
||||||
repository: 'platform/lms-be',
|
|
||||||
branch: 'release',
|
|
||||||
commit: '3ac92ef',
|
|
||||||
sourceType: 'migration',
|
|
||||||
target: 'prisma/migrations',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'ADS IDE',
|
|
||||||
component: 'ads-ide-fe',
|
|
||||||
repository: 'platform/ads-ide-fe',
|
|
||||||
branch: 'develop',
|
|
||||||
commit: 'b902a11',
|
|
||||||
sourceType: 'electron',
|
|
||||||
target: 'ADS-IDE-1.1.0.AppImage',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'ADS IDE',
|
|
||||||
component: 'ads-ide-be',
|
|
||||||
repository: 'platform/ads-ide-be',
|
|
||||||
branch: 'develop',
|
|
||||||
commit: '10fd8b0',
|
|
||||||
sourceType: 'container',
|
|
||||||
target: 'registry.local/ads-ide-be:0.1.0',
|
|
||||||
status: '주의'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appBuildJobs = [
|
|
||||||
{ app: 'LMS', component: 'lms-fe', branch: 'release', job: 'electron build', artifact: 'LMS-1.0.0.AppImage', duration: '6분 12초', status: '완료', time: '12분 전' },
|
|
||||||
{ app: 'LMS', component: 'lms-be', branch: 'release', job: 'image build', artifact: 'registry.local/lms-be:1.0.0', duration: '4분 31초', status: '완료', time: '10분 전' },
|
|
||||||
{ app: 'LMS', component: 'lms-adm-fe', branch: 'release', job: 'web image build', artifact: 'registry.local/lms-adm-fe:0.1.0', duration: '3분 18초', status: '완료', time: '9분 전' },
|
|
||||||
{ app: 'ADS IDE', component: 'ads-ide-be', branch: 'develop', job: 'image build', artifact: 'registry.local/ads-ide-be:0.1.0', duration: '진행 중', status: '실행 중', time: '3분 전' },
|
|
||||||
{ app: 'FMS', component: 'fms-api', branch: 'develop', job: 'image build', artifact: '미생성', duration: '1분 44초', status: '주의', time: '24분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appReleasePipelines = [
|
|
||||||
{ app: 'LMS', version: '1.0.0', sourceBranch: 'release', releaseBranch: 'main', commit: '3ac92ef', artifacts: 'electron 1, images 2, migrations 4', status: '배포됨', createdAt: '06.18.' },
|
|
||||||
{ app: 'ADS IDE', version: '1.1.0', sourceBranch: 'develop', releaseBranch: 'release', commit: 'b902a11', artifacts: 'electron 1, images 1', status: '카나리', createdAt: '06.16.' },
|
|
||||||
{ app: 'FMS', version: '2.3.1', sourceBranch: 'develop', releaseBranch: 'release', commit: 'c82f09d', artifacts: 'images 1', status: '초안', createdAt: '06.14.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appDeploymentJobs = [
|
|
||||||
{ app: 'LMS', environment: '운영', release: '1.0.0', strategy: 'rolling update', target: 'stdo-lms', status: '완료', requestedBy: 'kim.admin', time: '8분 전' },
|
|
||||||
{ app: 'ADS IDE', environment: '개발', release: '1.1.0-canary', strategy: 'redeploy', target: 'stdo-ads-ide', status: '실행 중', requestedBy: 'park.ops', time: '2분 전' },
|
|
||||||
{ app: 'FMS', environment: '개발', release: '2.3.1-dev', strategy: 'deploy', target: 'stdo-fms', status: '주의', requestedBy: 'choi.dev', time: '27분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appMigrationJobs = [
|
|
||||||
{ app: 'LMS', component: 'lms-db', branch: 'release', path: 'prisma/migrations', version: '20260618091000_add_course_indexes', status: '완료', time: '7분 전' },
|
|
||||||
{ app: 'LMS', component: 'lms-db', branch: 'release', path: 'prisma/migrations', version: '20260617083000_add_assignment_status', status: '완료', time: '1일 전' },
|
|
||||||
{ app: 'ADS IDE', component: 'ads-ide-db', branch: 'develop', path: 'migrations', version: '20260616070000_workspace_schema', status: '대기', time: '대기 중' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const containers = [
|
|
||||||
{ name: 'launcher-api', app: 'Autonomy Studio Launcher', image: 'atnmy-stdo-v2-be:local', cpu: 43, memory: 62, status: '실행 중' },
|
|
||||||
{ name: 'launcher-postgres', app: 'Autonomy Studio Launcher', image: 'postgres:launcher', cpu: 24, memory: 39, status: '실행 중' },
|
|
||||||
{ name: 'lms-api', app: 'LMS', image: 'lms-be:local', cpu: 67, memory: 59, status: '실행 중' },
|
|
||||||
{ name: 'ads-ide-api', app: 'ADS IDE', image: 'ads-ide-be:local', cpu: 89, memory: 84, status: '주의' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const k3sWorkloads = [
|
|
||||||
{
|
|
||||||
namespace: 'stdo-launcher',
|
|
||||||
app: 'Autonomy Studio Launcher',
|
|
||||||
workload: 'Deployment/launcher-api',
|
|
||||||
pod: 'launcher-api-7c9d85f9b7-q2x4m',
|
|
||||||
image: 'registry.local/atnmy-stdo-v2-be:1.0.1',
|
|
||||||
replicas: '2/2',
|
|
||||||
node: 'cluster-master-01',
|
|
||||||
cpu: 43,
|
|
||||||
memory: 62,
|
|
||||||
restarts: 0,
|
|
||||||
age: '3d',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-launcher',
|
|
||||||
app: 'Autonomy Studio Launcher',
|
|
||||||
workload: 'StatefulSet/launcher-postgres',
|
|
||||||
pod: 'launcher-postgres-0',
|
|
||||||
image: 'postgres:16',
|
|
||||||
replicas: '1/1',
|
|
||||||
node: 'cluster-master-01',
|
|
||||||
cpu: 24,
|
|
||||||
memory: 39,
|
|
||||||
restarts: 0,
|
|
||||||
age: '3d',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
app: 'LMS',
|
|
||||||
workload: 'Deployment/lms-api',
|
|
||||||
pod: 'lms-api-5f6db8d675-jt2xk',
|
|
||||||
image: 'registry.local/lms-be:1.0.0',
|
|
||||||
replicas: '2/2',
|
|
||||||
node: 'cluster-worker-01',
|
|
||||||
cpu: 67,
|
|
||||||
memory: 59,
|
|
||||||
restarts: 1,
|
|
||||||
age: '18h',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
app: 'LMS',
|
|
||||||
workload: 'Deployment/lms-adm-web',
|
|
||||||
pod: 'lms-adm-web-778b4d4f9b-bxz92',
|
|
||||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
|
||||||
replicas: '1/1',
|
|
||||||
node: 'cluster-worker-02',
|
|
||||||
cpu: 31,
|
|
||||||
memory: 44,
|
|
||||||
restarts: 0,
|
|
||||||
age: '18h',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
app: 'LMS',
|
|
||||||
workload: 'StatefulSet/lms-postgres',
|
|
||||||
pod: 'lms-postgres-0',
|
|
||||||
image: 'postgres:16',
|
|
||||||
replicas: '1/1',
|
|
||||||
node: 'cluster-worker-01',
|
|
||||||
cpu: 18,
|
|
||||||
memory: 71,
|
|
||||||
restarts: 0,
|
|
||||||
age: '18h',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-ads-ide',
|
|
||||||
app: 'ADS IDE',
|
|
||||||
workload: 'Deployment/ads-ide-api',
|
|
||||||
pod: 'ads-ide-api-84b8787d7d-r8qkp',
|
|
||||||
image: 'registry.local/ads-ide-be:0.1.0',
|
|
||||||
replicas: '1/1',
|
|
||||||
node: 'cluster-worker-02',
|
|
||||||
cpu: 89,
|
|
||||||
memory: 84,
|
|
||||||
restarts: 4,
|
|
||||||
age: '7h',
|
|
||||||
status: '주의'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const containerBlueprints = [
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
release: 'lms-1.0.0',
|
|
||||||
resources: [
|
|
||||||
{
|
|
||||||
name: 'lms-api',
|
|
||||||
kind: 'Deployment',
|
|
||||||
image: 'registry.local/lms-be:1.0.0',
|
|
||||||
replicas: 2,
|
|
||||||
ports: '4000/TCP',
|
|
||||||
env: 14,
|
|
||||||
secrets: 5,
|
|
||||||
storage: '-',
|
|
||||||
ingress: 'api.lms.apps.local',
|
|
||||||
status: '구성완료'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-adm-web',
|
|
||||||
kind: 'Deployment',
|
|
||||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
|
||||||
replicas: 1,
|
|
||||||
ports: '80/TCP',
|
|
||||||
env: 6,
|
|
||||||
secrets: 2,
|
|
||||||
storage: '-',
|
|
||||||
ingress: 'admin.lms.apps.local',
|
|
||||||
status: '구성완료'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-postgres',
|
|
||||||
kind: 'StatefulSet',
|
|
||||||
image: 'postgres:16',
|
|
||||||
replicas: 1,
|
|
||||||
ports: '5432/TCP',
|
|
||||||
env: 8,
|
|
||||||
secrets: 3,
|
|
||||||
storage: '20Gi / RWO',
|
|
||||||
ingress: '-',
|
|
||||||
status: '구성완료'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'ADS IDE',
|
|
||||||
namespace: 'stdo-ads-ide',
|
|
||||||
release: 'ads-ide-1.1.0',
|
|
||||||
resources: [
|
|
||||||
{
|
|
||||||
name: 'ads-ide-api',
|
|
||||||
kind: 'Deployment',
|
|
||||||
image: 'registry.local/ads-ide-be:0.1.0',
|
|
||||||
replicas: 1,
|
|
||||||
ports: '4300/TCP',
|
|
||||||
env: 9,
|
|
||||||
secrets: 4,
|
|
||||||
storage: '-',
|
|
||||||
ingress: 'api.ads-ide.apps.local',
|
|
||||||
status: '검토필요'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const tailscaleNodes = [
|
|
||||||
{
|
|
||||||
name: 'cluster-master-01',
|
|
||||||
ip: '100.64.0.10',
|
|
||||||
role: 'control-plane',
|
|
||||||
user: 'ops@autonomystudio.local',
|
|
||||||
routes: '10.42.0.0/16, 10.43.0.0/16',
|
|
||||||
lastSeen: '1분 전',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'cluster-worker-01',
|
|
||||||
ip: '100.64.0.11',
|
|
||||||
role: 'worker',
|
|
||||||
user: 'ops@autonomystudio.local',
|
|
||||||
routes: '-',
|
|
||||||
lastSeen: '3분 전',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'gpu-runner-02',
|
|
||||||
ip: '100.64.20.2',
|
|
||||||
role: 'gpu-runner',
|
|
||||||
user: 'mlops@autonomystudio.local',
|
|
||||||
routes: '-',
|
|
||||||
lastSeen: '오프라인',
|
|
||||||
status: '주의'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const tailscaleSettings = [
|
|
||||||
{
|
|
||||||
name: 'Tailnet',
|
|
||||||
key: 'TAILSCALE_TAILNET',
|
|
||||||
value: 'autonomy-studio.ts.net',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처와 클러스터 운영 노드가 연결되는 tailnet'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Subnet Routes',
|
|
||||||
key: 'TAILSCALE_ADVERTISE_ROUTES',
|
|
||||||
value: '10.42.0.0/16,10.43.0.0/16',
|
|
||||||
status: '활성',
|
|
||||||
description: 'Pod/Service CIDR 접근용 광고 라우트'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Platform Gateway 연동',
|
|
||||||
key: 'TAILSCALE_GATEWAY_PROVIDER',
|
|
||||||
value: 'platform-gateway',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처에는 연결 상태만 제공하고 세부 조회는 플랫폼 게이트웨이가 담당'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const gatewayConnections = [
|
|
||||||
{ name: 'Headscale Server', endpoint: 'https://headscale.autonomystudio.local', role: '노드/ACL 동기화', status: '활성', lastSync: '1분 전' },
|
|
||||||
{ name: 'Autonomy Agent', endpoint: 'agent heartbeat stream', role: 'Agent 등록/상태 집계', status: '활성', lastSync: '30초 전' },
|
|
||||||
{ name: 'Platform Gitea', endpoint: 'https://gitea.autonomystudio.local', role: '런처/앱 소스 버전 저장소', status: '활성', lastSync: '5분 전' },
|
|
||||||
{ name: 'Platform Runtime', endpoint: 'runtime command bus', role: '원격 명령/로그/이벤트', status: '주의', lastSync: '12분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const agentDevices = [
|
|
||||||
{ name: 'lab-mkmini-01', account: 'student01@example.com', status: '활성', workspace: '~/autonomy-studio/workspaces', sshKey: '배포됨', lastSeen: '2분 전' },
|
|
||||||
{ name: 'lab-f1tenth-03', account: 'student03@example.com', status: '활성', workspace: '~/autonomy-studio/workspaces', sshKey: '배포됨', lastSeen: '4분 전' },
|
|
||||||
{ name: 'sim-runner-02', account: 'instructor@example.com', status: '주의', workspace: '/opt/studio/workspaces', sshKey: '대기', lastSeen: '35분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const runtimeCommands = [
|
|
||||||
{ target: 'lab-mkmini-01', command: 'workspace sync', status: '완료', requestedBy: 'park.ops', time: '8분 전' },
|
|
||||||
{ target: 'lab-f1tenth-03', command: 'build module', status: '실행 중', requestedBy: 'kim.admin', time: '3분 전' },
|
|
||||||
{ target: 'sim-runner-02', command: 'collect logs', status: '주의', requestedBy: 'choi.dev', time: '22분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const notices = [
|
|
||||||
{ scope: '런처', title: 'v1.0.1 업데이트 안내', audience: '전체 사용자', status: '게시됨', date: '04.23.' },
|
|
||||||
{ scope: 'ADS IDE', title: '정기 점검 안내', audience: 'ADS IDE 사용자', status: '게시됨', date: '04.10.' },
|
|
||||||
{ scope: 'LMS', title: '신규 과제 검토 요청', audience: '강사', status: '게시됨', date: '06.02.' },
|
|
||||||
{ scope: '런처', title: '로그인 화면 점검 배너', audience: '전체 사용자', status: '예약됨', date: '06.24.' },
|
|
||||||
{ scope: 'FMS', title: 'FMS 릴리즈 사전 안내', audience: 'FMS 사용자', status: '초안', date: '06.25.' },
|
|
||||||
{ scope: 'ADS Education', title: '교육 콘텐츠 업데이트', audience: 'ADS Education 사용자', status: '게시됨', date: '06.12.' },
|
|
||||||
{ scope: 'Data Management', title: '베타 신청 접수', audience: '운영자', status: '초안', date: '06.18.' },
|
|
||||||
{ scope: '런처', title: '구버전 런처 지원 종료 안내', audience: '전체 사용자', status: '예약됨', date: '07.01.' },
|
|
||||||
{ scope: 'Calibration', title: '캘리브레이션 템플릿 변경', audience: 'Calibration 사용자', status: '게시됨', date: '06.09.' },
|
|
||||||
{ scope: 'HDMAP Builder', title: 'HDMAP Builder 등록 예정', audience: '운영자', status: '초안', date: '06.28.' },
|
|
||||||
{ scope: '런처', title: '파일 서버 점검 공지', audience: '전체 사용자', status: '게시됨', date: '06.15.' },
|
|
||||||
{ scope: 'ADS IDE', title: 'IDE 확장 기능 업데이트', audience: 'ADS IDE 사용자', status: '예약됨', date: '06.30.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const alerts = [
|
|
||||||
{ icon: Bell, label: '확인이 필요한 워크로드 3개' },
|
|
||||||
{ icon: FileKey, label: '14일 내 만료되는 라이센스 42개' },
|
|
||||||
{ icon: KeyRound, label: '이번 주 교체된 시크릿 5개' }
|
|
||||||
];
|
|
||||||
+24
-21
@@ -1,12 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
|
BookOpen,
|
||||||
Boxes,
|
Boxes,
|
||||||
ChartNoAxesColumnIncreasing,
|
ChartNoAxesColumnIncreasing,
|
||||||
FileKey,
|
FileKey,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
|
MonitorPlay,
|
||||||
|
MonitorUp,
|
||||||
Network,
|
Network,
|
||||||
Rocket,
|
ScrollText,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users
|
Users
|
||||||
@@ -35,9 +38,19 @@ export const navGroups: NavGroup[] = [
|
|||||||
label: '플랫폼',
|
label: '플랫폼',
|
||||||
items: [
|
items: [
|
||||||
{ label: '대시보드', path: '/dashboard', icon: LayoutDashboard },
|
{ label: '대시보드', path: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{
|
||||||
|
label: '공통앱',
|
||||||
|
icon: ServerCog,
|
||||||
|
children: [
|
||||||
|
{ label: '소스 관리', path: '/common-apps/sources' },
|
||||||
|
{ label: '빌드 관리', path: '/common-apps/builds' },
|
||||||
|
{ label: '구성요소', path: '/common-apps/components' },
|
||||||
|
{ label: '로그/이벤트', path: '/common-apps/logs' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '런처',
|
label: '런처',
|
||||||
icon: Rocket,
|
icon: MonitorPlay,
|
||||||
children: [
|
children: [
|
||||||
{ label: '소스 관리', path: '/launcher/sources' },
|
{ label: '소스 관리', path: '/launcher/sources' },
|
||||||
{ label: '빌드 관리', path: '/launcher/builds' },
|
{ label: '빌드 관리', path: '/launcher/builds' },
|
||||||
@@ -69,30 +82,18 @@ export const navGroups: NavGroup[] = [
|
|||||||
{ label: '노드 현황', path: '/tailscale/status' },
|
{ label: '노드 현황', path: '/tailscale/status' },
|
||||||
{ label: '라우트', path: '/tailscale/routes' },
|
{ label: '라우트', path: '/tailscale/routes' },
|
||||||
{ label: 'ACL', path: '/tailscale/acl' },
|
{ label: 'ACL', path: '/tailscale/acl' },
|
||||||
{ label: '연결 진단', path: '/tailscale/diagnostics' }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '에이전트',
|
label: '플랫폼 관리',
|
||||||
icon: ShieldCheck,
|
icon: MonitorUp,
|
||||||
children: [
|
children: [
|
||||||
{ label: '장비 등록', path: '/agents/registration' },
|
{ label: '플랫폼 등록', path: '/agents/registration' },
|
||||||
{ label: '장비 제어', path: '/runtime/devices' },
|
{ label: '연결 제어', path: '/runtime/devices' },
|
||||||
{ label: '설치 상태', path: '/agents/install-status' },
|
{ label: 'SSH 키', path: '/agents/ssh-keys' },
|
||||||
{ label: 'SSH 키 배포', path: '/agents/ssh-keys' },
|
|
||||||
{ label: 'Workspace 설정', path: '/agents/workspace' },
|
|
||||||
{ label: '원격 명령', path: '/agents/commands' }
|
{ label: '원격 명령', path: '/agents/commands' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: '플랫폼 게이트웨이',
|
|
||||||
icon: ServerCog,
|
|
||||||
children: [
|
|
||||||
{ label: '연결 상태', path: '/gateway/status' },
|
|
||||||
{ label: 'Agent 관리', path: '/gateway/agents' },
|
|
||||||
{ label: '장비 동기화', path: '/gateway/devices' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: '인프라',
|
label: '인프라',
|
||||||
icon: HardDrive,
|
icon: HardDrive,
|
||||||
@@ -110,9 +111,11 @@ export const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
||||||
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
||||||
{ label: '사용자/세션', path: '/operations/users', icon: Users },
|
{ label: '사용자 관리', path: '/operations/users', icon: Users },
|
||||||
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
||||||
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
{ label: '공지사항', path: '/operations/notices', icon: Megaphone },
|
||||||
|
{ label: '약관', path: '/operations/terms', icon: ScrollText },
|
||||||
|
{ label: '매뉴얼', path: '/operations/manuals', icon: BookOpen }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+354
-102
@@ -1,18 +1,31 @@
|
|||||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-react';
|
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
||||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
||||||
import {
|
|
||||||
agentDevices,
|
|
||||||
alerts,
|
|
||||||
gatewayConnections,
|
|
||||||
k3sWorkloads,
|
|
||||||
launcherVersions,
|
|
||||||
runtimeCommands,
|
|
||||||
tailscaleSettings
|
|
||||||
} from '../data/mock';
|
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { PageTitle } from '../layout/AppLayout';
|
import { PageTitle } from '../layout/AppLayout';
|
||||||
|
import {
|
||||||
|
cancelDeviceAuthorization,
|
||||||
|
createDeviceAuthorization,
|
||||||
|
loadDeviceAuthorizations,
|
||||||
|
loadPlatformDevices,
|
||||||
|
loadPlatformSshKeys,
|
||||||
|
searchPlatformRegistrationUsers,
|
||||||
|
type DeviceAuthorization,
|
||||||
|
type PlatformDevice,
|
||||||
|
type PlatformRegistrationUser,
|
||||||
|
type PlatformSshKey,
|
||||||
|
} from './agents/platform-api';
|
||||||
|
|
||||||
|
const launcherVersions: Array<{
|
||||||
|
name: string; status: string; latestVersion: string; minimumSupportedVersion?: string;
|
||||||
|
artifact: string; downloadUrl: string; releaseNotes: string;
|
||||||
|
}> = [];
|
||||||
|
const tailscaleSettings: Array<{ key: string; name: string; description: string; value: string; status: string }> = [];
|
||||||
|
const gatewayConnections: Array<{ name: string; status: string; role: string; endpoint: string; lastSync: string }> = [];
|
||||||
|
const runtimeCommands: Array<{ target: string; command: string; requestedBy: string; time: string; status: string }> = [];
|
||||||
|
const k3sWorkloads: Array<{ workload: string; namespace: string; pod: string; replicas: string; cpu: number; memory: number; status: string }> = [];
|
||||||
|
const alerts: Array<{ label: string; icon: typeof Settings2 }> = [];
|
||||||
|
|
||||||
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
||||||
|
|
||||||
@@ -318,6 +331,8 @@ export function TailscaleStatusPage() {
|
|||||||
const [source, setSource] = useState('');
|
const [source, setSource] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<TailscaleNode | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
|
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
|
||||||
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
||||||
|
|
||||||
@@ -340,6 +355,20 @@ export function TailscaleStatusPage() {
|
|||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const deleteNode = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
setError('');
|
||||||
|
apiRequest<{ deleted: boolean }>(`/tailscale/nodes/${encodeURIComponent(deleteTarget.id)}`, { method: 'DELETE' })
|
||||||
|
.then(() => {
|
||||||
|
setNodes((current) => current.filter((node) => node.id !== deleteTarget.id));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((requestError: Error) => setError(requestError.message))
|
||||||
|
.finally(() => setDeleting(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -372,13 +401,30 @@ export function TailscaleStatusPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TailscaleNodeTable nodes={nodes} loading={loading} />
|
<TailscaleNodeTable nodes={nodes} loading={loading} onDelete={setDeleteTarget} />
|
||||||
</div>
|
</div>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="노드 삭제"
|
||||||
|
onClose={() => !deleting && setDeleteTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>취소</Button>
|
||||||
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deleting} onClick={deleteNode}>
|
||||||
|
{deleting ? '삭제 중...' : '삭제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<strong className="text-foreground">{deleteTarget?.name}</strong> 노드를 Headscale에서 삭제합니다. 연결된 기기의 VPN 연결은 즉시 끊기며, 다시 사용하려면 재등록해야 합니다.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
|
function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode[]; loading: boolean; onDelete: (node: TailscaleNode) => void }) {
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
|
||||||
@@ -407,17 +453,18 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin
|
|||||||
<th className="px-5 py-3 font-medium">Routes</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">Last Seen</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"><span className="sr-only">관리</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{loading && nodes.length === 0 ? (
|
{loading && nodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
|
<td colSpan={8} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && nodes.length === 0 ? (
|
{!loading && nodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
<td colSpan={8} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{pageNodes.map((node) => (
|
{pageNodes.map((node) => (
|
||||||
@@ -429,6 +476,17 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin
|
|||||||
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
||||||
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
||||||
|
<td className="px-5 py-3 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 p-0 text-muted-foreground hover:text-rose-600"
|
||||||
|
title="노드 삭제"
|
||||||
|
aria-label={`${node.name} 노드 삭제`}
|
||||||
|
onClick={() => onDelete(node)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -782,81 +840,238 @@ export function RuntimeDevicesPage() {
|
|||||||
<AgentTable
|
<AgentTable
|
||||||
title="장비 목록"
|
title="장비 목록"
|
||||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||||
withCommands
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentTable({ title, description, withCommands = false }: { title: string; description: string; withCommands?: boolean }) {
|
type NumberedPlatformDevice = PlatformDevice & { no: number };
|
||||||
|
type NumberedDeviceAuthorization = DeviceAuthorization & { no: number };
|
||||||
|
type NumberedPlatformSshKey = PlatformSshKey & { no: number };
|
||||||
|
|
||||||
|
function AgentTable({ title, description }: { title: string; description: string }) {
|
||||||
|
const [devices, setDevices] = useState<PlatformDevice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
loadPlatformDevices()
|
||||||
|
.then(setDevices)
|
||||||
|
.catch(() => setDevices([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const rows = useMemo<NumberedPlatformDevice[]>(
|
||||||
|
() => devices.map((device, index) => ({ ...device, no: devices.length - index })),
|
||||||
|
[devices]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<NumberedPlatformDevice>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||||
|
{
|
||||||
|
key: 'platform',
|
||||||
|
header: '플랫폼',
|
||||||
|
flexible: true,
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{row.hostname}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.tailscaleHostname || '-'}>{row.tailscaleHostname || '-'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ key: 'account', header: '계정', width: 210, cell: (row) => <span className="block truncate text-muted-foreground" title={row.accountEmail}>{row.accountEmail}</span> },
|
||||||
|
{ key: 'workspace', header: 'Workspace', width: 280, cell: (row) => <span className="block truncate text-muted-foreground" title={row.workspaceRoot || '-'}>{row.workspaceRoot || '-'}</span> },
|
||||||
|
{ key: 'ssh', header: 'SSH 사용자', width: 130, cell: (row) => row.sshUser || '-' },
|
||||||
|
{ key: 'lastSeen', header: '최근 연결', width: 180, cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatTailscaleLastSeen(row.lastProvisionedAt)}</span> },
|
||||||
|
{ key: 'status', header: '상태', width: 110, cell: (row) => <StatusBadge status={row.status} /> }
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<DataTableCard
|
||||||
<CardHeader
|
title={title}
|
||||||
title={title}
|
description={description}
|
||||||
description={description}
|
rows={rows}
|
||||||
action={
|
columns={columns}
|
||||||
withCommands ? (
|
searchPlaceholder="플랫폼명, 계정, 워크스페이스 검색"
|
||||||
<div className="flex gap-2">
|
getSearchText={(row) => `${row.hostname} ${row.tailscaleHostname ?? ''} ${row.accountEmail} ${row.workspaceRoot ?? ''} ${row.sshUser ?? ''}`}
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
getRowKey={(row) => row.id}
|
||||||
<SlidersHorizontal className="size-4" />
|
pageSize={6}
|
||||||
상태 필터
|
minWidth={1080}
|
||||||
</Button>
|
loading={loading}
|
||||||
<Button className="h-8 px-3 text-[13px]">
|
emptyMessage="등록된 플랫폼이 없습니다."
|
||||||
<RefreshCcw className="size-4" />
|
/>
|
||||||
상태 갱신
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
|
||||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
|
||||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="px-5 py-3 font-medium">장비</th>
|
|
||||||
<th className="px-5 py-3 font-medium">계정</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
|
||||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
|
||||||
<th className="px-5 py-3 font-medium">상태</th>
|
|
||||||
{withCommands ? <th className="px-5 py-3 text-right font-medium">명령</th> : null}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{agentDevices.map((device) => (
|
|
||||||
<tr className="hover:bg-muted/20" key={device.name}>
|
|
||||||
<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"><StatusBadge status={device.status} /></td>
|
|
||||||
{withCommands ? (
|
|
||||||
<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="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
|
||||||
<Button variant="ghost" className="size-8 px-0" aria-label="상세">
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
) : null}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentRegistrationPage() {
|
export function AgentRegistrationPage() {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [users, setUsers] = useState<PlatformRegistrationUser[]>([]);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<PlatformRegistrationUser | null>(null);
|
||||||
|
const [authorizations, setAuthorizations] = useState<DeviceAuthorization[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [issuing, setIssuing] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [cancelTarget, setCancelTarget] = useState<DeviceAuthorization | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
loadDeviceAuthorizations()
|
||||||
|
.then(setAuthorizations)
|
||||||
|
.catch(() => setAuthorizations([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const value = keyword.trim();
|
||||||
|
if (value.length < 2 || selectedUser) {
|
||||||
|
setUsers([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
searchPlatformRegistrationUsers(value).then(setUsers).catch(() => setUsers([]));
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [keyword, selectedUser]);
|
||||||
|
|
||||||
|
const issue = async () => {
|
||||||
|
if (!selectedUser || issuing) return;
|
||||||
|
setIssuing(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await createDeviceAuthorization(selectedUser.id);
|
||||||
|
await refresh();
|
||||||
|
} catch (issueError) {
|
||||||
|
setError(issueError instanceof Error ? issueError.message : '플랫폼 등록 키를 발급하지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIssuing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = async () => {
|
||||||
|
if (!cancelTarget) return;
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await cancelDeviceAuthorization(cancelTarget.id);
|
||||||
|
setCancelTarget(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (cancelError) {
|
||||||
|
setError(cancelError instanceof Error ? cancelError.message : '플랫폼 등록 키를 취소하지 못했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = useMemo<NumberedDeviceAuthorization[]>(
|
||||||
|
() => authorizations.map((authorization, index) => ({ ...authorization, no: authorizations.length - index })),
|
||||||
|
[authorizations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<NumberedDeviceAuthorization>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
header: '대상 사용자',
|
||||||
|
flexible: true,
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{row.authUserName || row.accountEmail || '-'}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{row.accountEmail || '-'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ key: 'code', header: '등록 키', width: 150, cell: (row) => <span className="font-mono font-semibold tracking-[0.12em]">{row.userCode}</span> },
|
||||||
|
{ key: 'expires', header: '만료', width: 190, cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatTailscaleLastSeen(row.expiresAt)}</span> },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: '상태',
|
||||||
|
width: 120,
|
||||||
|
cell: (row) => <StatusBadge status={row.status === 'APPROVED' ? '발급됨' : row.status === 'CANCELLED' ? '취소됨' : row.status === 'CONSUMED' ? '사용됨' : row.status === 'EXPIRED' ? '만료됨' : row.status} badgeTone={row.status === 'APPROVED' ? 'green' : row.status === 'CANCELLED' || row.status === 'EXPIRED' ? 'neutral' : undefined} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||||
|
cell: (row) => row.status === 'APPROVED' ? <div className="flex justify-end"><Button variant="ghost" className="h-8 px-2 text-[13px] text-rose-300 hover:text-rose-200" onClick={() => setCancelTarget(row)}>취소</Button></div> : null,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="Agent" title="장비 등록" description="새 장비에 설치할 Agent 등록 토큰과 연결 정보를 발급합니다." />
|
<PageTitle eyebrow="플랫폼 관리" title="플랫폼 등록" description="에이전트 설치에 사용할 사용자별 6자리 등록 키를 발급하고 관리합니다." />
|
||||||
<AgentPlaceholder title="장비 등록 요청" description="Agent 등록 요청과 토큰 발급 상태를 확인합니다." />
|
<Card className="mb-4">
|
||||||
|
<CardHeader title="플랫폼 등록 키" description="대상 사용자를 선택한 뒤 발급한 키와 계정 이메일을 에이전트 설치 화면에 입력합니다." />
|
||||||
|
<div className="border-t border-border p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||||
|
<label className="relative min-w-0 flex-1">
|
||||||
|
<span className="mb-2 block text-xs font-medium text-muted-foreground">대상 사용자</span>
|
||||||
|
<input
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="이름 또는 이메일 검색"
|
||||||
|
value={selectedUser ? `${selectedUser.name || selectedUser.email} · ${selectedUser.email}` : keyword}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setKeyword(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{users.length ? (
|
||||||
|
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border border-border bg-card py-1 shadow-xl">
|
||||||
|
{users.map((user) => (
|
||||||
|
<button key={user.id} type="button" className="block w-full px-3 py-2 text-left hover:bg-muted" onClick={() => { setSelectedUser(user); setKeyword(''); setUsers([]); }}>
|
||||||
|
<span className="block text-sm font-medium">{user.name || user.email}</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-muted-foreground">{user.email}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
{selectedUser ? <Button variant="outline" className="h-9 px-3 text-[13px]" onClick={() => { setSelectedUser(null); setKeyword(''); }}>선택 해제</Button> : null}
|
||||||
|
<Button className="h-9 px-3 text-[13px]" disabled={!selectedUser || issuing} onClick={() => void issue()}>
|
||||||
|
키 발급
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="mt-3 text-sm text-rose-300">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<DataTableCard
|
||||||
|
title="등록 키 목록"
|
||||||
|
description="활성 키와 최근 24시간 내 발급된 키를 확인합니다."
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
searchPlaceholder="사용자명, 이메일, 등록 키 검색"
|
||||||
|
getSearchText={(row) => `${row.authUserName ?? ''} ${row.accountEmail ?? ''} ${row.userCode} ${row.status}`}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pageSize={6}
|
||||||
|
minWidth={920}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="발급된 플랫폼 등록 키가 없습니다."
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(cancelTarget)}
|
||||||
|
title="등록 키 취소"
|
||||||
|
onClose={() => setCancelTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCancelTarget(null)}>닫기</Button>
|
||||||
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" onClick={() => void cancel()}>취소</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">선택한 등록 키를 취소하면 에이전트 설치에 더 이상 사용할 수 없습니다.</p>
|
||||||
|
<p className="mt-3 font-mono text-sm font-semibold tracking-[0.12em] text-foreground">{cancelTarget?.userCode}</p>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -864,26 +1079,74 @@ export function AgentRegistrationPage() {
|
|||||||
export function AgentInstallStatusPage() {
|
export function AgentInstallStatusPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="Agent" title="설치 상태" description="장비별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
<PageTitle eyebrow="플랫폼 관리" title="연결 상태" description="플랫폼별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
||||||
<AgentPlaceholder title="설치 상태" description="설치 완료, 대기, 실패 상태를 확인합니다." />
|
<AgentTable title="연결 상태" description="플랫폼별 Agent 연결 상태를 확인합니다." />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentSshKeysPage() {
|
export function AgentSshKeysPage() {
|
||||||
return (
|
const [keys, setKeys] = useState<PlatformSshKey[]>([]);
|
||||||
<>
|
const [loading, setLoading] = useState(true);
|
||||||
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
const [selectedKey, setSelectedKey] = useState<PlatformSshKey | null>(null);
|
||||||
<AgentPlaceholder title="SSH 키 배포" description="장비별 SSH 키 배포 상태입니다." />
|
|
||||||
</>
|
useEffect(() => {
|
||||||
);
|
loadPlatformSshKeys()
|
||||||
}
|
.then(setKeys)
|
||||||
|
.catch(() => setKeys([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rows = useMemo<NumberedPlatformSshKey[]>(
|
||||||
|
() => keys.map((key, index) => ({ ...key, no: keys.length - index })),
|
||||||
|
[keys]
|
||||||
|
);
|
||||||
|
|
||||||
export function AgentWorkspacePage() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="Agent" title="Workspace 설정" description="장비별 작업 디렉터리와 동기화 정책을 관리합니다." />
|
<PageTitle eyebrow="플랫폼 관리" title="SSH 키" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
||||||
<AgentPlaceholder title="Workspace 설정" description="계정별 workspace 경로와 동기화 상태입니다." />
|
<DataTableCard
|
||||||
|
title="SSH 키 목록"
|
||||||
|
description="등록 플랫폼 계정에 연결된 SSH 키입니다."
|
||||||
|
rows={rows}
|
||||||
|
columns={[
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||||
|
{ key: 'account', header: '계정', flexible: true, cell: (row) => row.accountEmail },
|
||||||
|
{ key: 'label', header: '이름', width: 180, cell: (row) => row.label || '-' },
|
||||||
|
{ key: 'type', header: '유형', width: 140, cell: (row) => row.keyType || '-' },
|
||||||
|
{ key: 'updated', header: '최근 등록', width: 180, cell: (row) => formatTailscaleLastSeen(row.lastProvisionedAt) },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="메뉴"
|
||||||
|
aria-label="메뉴"
|
||||||
|
onClick={() => setSelectedKey(row)}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
getSearchText={(row) => `${row.accountEmail} ${row.label ?? ''} ${row.publicKeyHash}`}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="등록된 SSH 키가 없습니다."
|
||||||
|
/>
|
||||||
|
<Modal open={Boolean(selectedKey)} title="SSH 키 지문" onClose={() => setSelectedKey(null)}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">{selectedKey?.label || selectedKey?.accountEmail}</p>
|
||||||
|
<p className="break-all rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs text-foreground">
|
||||||
|
{selectedKey?.publicKeyHash}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -902,7 +1165,7 @@ export function AgentCommandsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
<PageTitle eyebrow="플랫폼 관리" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="최근 제어 작업"
|
title="최근 제어 작업"
|
||||||
rows={runtimeCommands}
|
rows={runtimeCommands}
|
||||||
@@ -915,17 +1178,6 @@ export function AgentCommandsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentPlaceholder({ title, description }: { title: string; description: string }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader title={title} description={description} />
|
|
||||||
<div className="p-5">
|
|
||||||
<AgentTable title="Agent 장비" description="등록된 장비 목록입니다." />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MonitoringPage() {
|
export function MonitoringPage() {
|
||||||
const columns = useMemo<Array<DataTableColumn<(typeof k3sWorkloads)[number]>>>(
|
const columns = useMemo<Array<DataTableColumn<(typeof k3sWorkloads)[number]>>>(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
|
||||||
|
export type PlatformDevice = {
|
||||||
|
id: string;
|
||||||
|
accountEmail: string;
|
||||||
|
hostname: string;
|
||||||
|
tailscaleHostname: string;
|
||||||
|
sshUser?: string | null;
|
||||||
|
workspaceRoot?: string | null;
|
||||||
|
usageType: string;
|
||||||
|
os?: string | null;
|
||||||
|
arch?: string | null;
|
||||||
|
distro?: string | null;
|
||||||
|
agentVersion?: string | null;
|
||||||
|
agentUpdatedAt?: string | null;
|
||||||
|
agentLastSeenAt?: string | null;
|
||||||
|
installedAt: string;
|
||||||
|
lastProvisionedAt: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlatformSshKey = {
|
||||||
|
id: string;
|
||||||
|
accountEmail: string;
|
||||||
|
label?: string | null;
|
||||||
|
publicKeyHash: string;
|
||||||
|
keyType?: string | null;
|
||||||
|
lastProvisionedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlatformRegistrationUser = {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeviceAuthorization = {
|
||||||
|
id: string;
|
||||||
|
userCode: string;
|
||||||
|
status: string;
|
||||||
|
authUserId?: string | null;
|
||||||
|
accountEmail?: string | null;
|
||||||
|
authUserName?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadPlatformDevices() {
|
||||||
|
const response = await apiRequest<{ devices?: PlatformDevice[] }>('/agents/devices');
|
||||||
|
return response.devices ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlatformSshKeys() {
|
||||||
|
const response = await apiRequest<{ sshKeys?: PlatformSshKey[] }>('/agents/ssh-keys');
|
||||||
|
return response.sshKeys ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPlatformRegistrationUsers(keyword: string) {
|
||||||
|
const response = await apiRequest<{ users?: PlatformRegistrationUser[] }>(`/agents/registration-users?keyword=${encodeURIComponent(keyword)}`);
|
||||||
|
return response.users ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDeviceAuthorizations() {
|
||||||
|
const response = await apiRequest<{ authorizations?: DeviceAuthorization[] }>('/agents/device-authorizations');
|
||||||
|
return response.authorizations ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDeviceAuthorization(userId: string) {
|
||||||
|
const response = await apiRequest<{ authorization: DeviceAuthorization }>('/agents/device-authorizations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
return response.authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelDeviceAuthorization(id: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/agents/device-authorizations/${id}/cancel`, { method: 'POST' });
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2, Upload } from 'lucide-react';
|
||||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { Requester } from '../../components/requester';
|
import { Requester } from '../../components/requester';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
@@ -7,7 +7,7 @@ import { PageTitle } from '../../layout/AppLayout';
|
|||||||
import type { BuildJob, SourceComponent } from '../../types/application';
|
import type { BuildJob, SourceComponent } from '../../types/application';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
||||||
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
import { cancelBuildJob, createBuildJob, createQuickDeployment, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
||||||
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||||
|
|
||||||
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||||
@@ -29,7 +29,7 @@ function buildTargetForType(type?: string | null) {
|
|||||||
|
|
||||||
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
||||||
if (!component) return '-';
|
if (!component) return '-';
|
||||||
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
|
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : '<commit-sha>';
|
||||||
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
||||||
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
||||||
return `localhost:5000/${component.name}:${suffix}`;
|
return `localhost:5000/${component.name}:${suffix}`;
|
||||||
@@ -64,6 +64,12 @@ function resultArtifact(job: BuildJob) {
|
|||||||
return job.image || job.artifact || '-';
|
return job.image || job.artifact || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isQuickDeployable(job: BuildJob) {
|
||||||
|
return job.status === 'SUCCEEDED'
|
||||||
|
&& Boolean(job.image)
|
||||||
|
&& ['CONTAINER', 'STATIC_WEB'].includes(job.component.type);
|
||||||
|
}
|
||||||
|
|
||||||
function jobTime(job: BuildJob) {
|
function jobTime(job: BuildJob) {
|
||||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||||
return Number.isNaN(value) ? 0 : value;
|
return Number.isNaN(value) ? 0 : value;
|
||||||
@@ -95,9 +101,12 @@ export function BuildManagementPage() {
|
|||||||
const [commitSha, setCommitSha] = useState('');
|
const [commitSha, setCommitSha] = useState('');
|
||||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<BuildJob | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<BuildJob | null>(null);
|
||||||
|
const [quickDeployTarget, setQuickDeployTarget] = useState<BuildJob | null>(null);
|
||||||
|
const [quickDeployNamespace, setQuickDeployNamespace] = useState('');
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deletingJob, setDeletingJob] = useState(false);
|
const [deletingJob, setDeletingJob] = useState(false);
|
||||||
|
const [quickDeploying, setQuickDeploying] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [range, setRange] = useState<BuildRange>('week');
|
const [range, setRange] = useState<BuildRange>('week');
|
||||||
const [totalJobs, setTotalJobs] = useState(0);
|
const [totalJobs, setTotalJobs] = useState(0);
|
||||||
@@ -276,6 +285,29 @@ export function BuildManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openQuickDeploy = (job: BuildJob) => {
|
||||||
|
setQuickDeployTarget(job);
|
||||||
|
setQuickDeployNamespace(job.component.app.namespace || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickDeploy = async () => {
|
||||||
|
if (!quickDeployTarget || !quickDeployNamespace.trim() || quickDeploying) return;
|
||||||
|
setQuickDeploying(true);
|
||||||
|
try {
|
||||||
|
await createQuickDeployment({
|
||||||
|
buildJobId: quickDeployTarget.id,
|
||||||
|
environment: 'prod',
|
||||||
|
namespace: quickDeployNamespace.trim()
|
||||||
|
});
|
||||||
|
toast({ type: 'success', message: '빠른 배포 작업이 실행되었습니다.' });
|
||||||
|
setQuickDeployTarget(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '빠른 배포에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setQuickDeploying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -404,6 +436,11 @@ export function BuildManagementPage() {
|
|||||||
<RotateCcw className="size-4" />
|
<RotateCcw className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isQuickDeployable(job) ? (
|
||||||
|
<Button variant="outline" className="size-8 px-0" title="빠른 배포" aria-label="빠른 배포" onClick={() => openQuickDeploy(job)}>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{isBuildDone(job.status) ? (
|
{isBuildDone(job.status) ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -506,6 +543,34 @@ export function BuildManagementPage() {
|
|||||||
<p className="text-muted-foreground">삭제된 빌드 작업 이력과 로그는 목록에서 사라집니다.</p>
|
<p className="text-muted-foreground">삭제된 빌드 작업 이력과 로그는 목록에서 사라집니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(quickDeployTarget)}
|
||||||
|
title="빠른 배포"
|
||||||
|
onClose={() => {
|
||||||
|
if (!quickDeploying) setQuickDeployTarget(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={quickDeploying} onClick={() => setQuickDeployTarget(null)}>취소</Button>
|
||||||
|
<Button disabled={quickDeploying || !quickDeployNamespace.trim()} onClick={quickDeploy}>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
{quickDeploying ? '배포 중' : '배포'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p className="text-muted-foreground">릴리즈를 만들지 않고 선택한 빌드 이미지로 현재 워크로드를 교체합니다. 런처 앱 버전은 갱신하지 않습니다.</p>
|
||||||
|
<div className="grid gap-2 rounded-md border border-border bg-muted/20 p-4 text-xs">
|
||||||
|
<div className="flex items-center justify-between gap-4"><span className="text-muted-foreground">구성요소</span><span className="font-medium">{quickDeployTarget?.component.name ?? '-'}</span></div>
|
||||||
|
<div className="min-w-0"><p className="text-muted-foreground">이미지</p><p className="mt-1 truncate font-mono font-medium">{quickDeployTarget?.image ?? '-'}</p></div>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">네임스페이스 <b className="text-foreground">*</b></span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={quickDeployNamespace} disabled={quickDeploying} onChange={(event) => setQuickDeployNamespace(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
title="빌드 실행 확인"
|
title="빌드 실행 확인"
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ type ImageTags = {
|
|||||||
artifacts?: Record<string, string>;
|
artifacts?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function releaseArtifacts(release?: AppRelease | null) {
|
function releaseArtifacts(release?: AppRelease | null, appName?: string) {
|
||||||
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
||||||
const entries = Object.entries(imageTags?.images ?? {});
|
const entries = Object.entries(imageTags?.images ?? {}).filter(([, image]) => (
|
||||||
|
!/^(postgres|mysql|mariadb|mongo)(:|@)/i.test(image.trim())
|
||||||
|
));
|
||||||
if (entries.length) return entries;
|
if (entries.length) return entries;
|
||||||
if (release?.artifact) return [[release.app.name, release.artifact]];
|
if (release?.artifact) return [[release.app?.name ?? appName ?? 'artifact', release.artifact]];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +79,11 @@ export function DeploymentManagementPage() {
|
|||||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||||
const [releaseId, setReleaseId] = useState('');
|
const [releaseId, setReleaseId] = useState('');
|
||||||
const [namespace, setNamespace] = useState('');
|
const [namespace, setNamespace] = useState('');
|
||||||
|
const [replicas, setReplicas] = useState('1');
|
||||||
|
const [cpuRequest, setCpuRequest] = useState('100m');
|
||||||
|
const [cpuLimit, setCpuLimit] = useState('500m');
|
||||||
|
const [memoryRequest, setMemoryRequest] = useState('128Mi');
|
||||||
|
const [memoryLimit, setMemoryLimit] = useState('256Mi');
|
||||||
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
||||||
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
||||||
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
||||||
@@ -158,6 +165,12 @@ export function DeploymentManagementPage() {
|
|||||||
setReleaseId(release.id);
|
setReleaseId(release.id);
|
||||||
setNamespace(release.app.namespace || '');
|
setNamespace(release.app.namespace || '');
|
||||||
}
|
}
|
||||||
|
if (!release && !releaseId && !releases[0]?.app?.namespace) setNamespace('');
|
||||||
|
setReplicas('1');
|
||||||
|
setCpuRequest('100m');
|
||||||
|
setCpuLimit('500m');
|
||||||
|
setMemoryRequest('128Mi');
|
||||||
|
setMemoryLimit('256Mi');
|
||||||
setUpdateLauncherAppVersion(true);
|
setUpdateLauncherAppVersion(true);
|
||||||
setDeployModalOpen(true);
|
setDeployModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -165,8 +178,22 @@ export function DeploymentManagementPage() {
|
|||||||
const deploy = async () => {
|
const deploy = async () => {
|
||||||
if (deploying) return;
|
if (deploying) return;
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
|
const requestedReplicas = Number.parseInt(replicas, 10);
|
||||||
|
const resources = {
|
||||||
|
...(Number.isFinite(requestedReplicas) && requestedReplicas > 0 ? { replicas: requestedReplicas } : {}),
|
||||||
|
...(cpuRequest.trim() ? { cpuRequest: cpuRequest.trim() } : {}),
|
||||||
|
...(cpuLimit.trim() ? { cpuLimit: cpuLimit.trim() } : {}),
|
||||||
|
...(memoryRequest.trim() ? { memoryRequest: memoryRequest.trim() } : {}),
|
||||||
|
...(memoryLimit.trim() ? { memoryLimit: memoryLimit.trim() } : {})
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const result = await createDeployment({ releaseId, environment: 'prod', namespace, updateLauncherAppVersion });
|
const result = await createDeployment({
|
||||||
|
releaseId,
|
||||||
|
environment: 'prod',
|
||||||
|
namespace,
|
||||||
|
updateLauncherAppVersion,
|
||||||
|
resources: Object.keys(resources).length ? resources : undefined
|
||||||
|
});
|
||||||
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
||||||
setSelectedDeployment(result.deploymentJob);
|
setSelectedDeployment(result.deploymentJob);
|
||||||
setDeployModalOpen(false);
|
setDeployModalOpen(false);
|
||||||
@@ -236,7 +263,7 @@ export function DeploymentManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{pagedDeployments.map((deployment, index) => {
|
{pagedDeployments.map((deployment, index) => {
|
||||||
const artifacts = releaseArtifacts(deployment.release);
|
const artifacts = releaseArtifacts(deployment.release, deployment.app?.name);
|
||||||
return (
|
return (
|
||||||
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
@@ -245,7 +272,7 @@ export function DeploymentManagementPage() {
|
|||||||
<span className="text-xs font-medium text-muted-foreground">No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + index)}</span>
|
<span className="text-xs font-medium text-muted-foreground">No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + index)}</span>
|
||||||
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
|
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
|
||||||
<span className="text-base font-medium">{deployment.app.name}</span>
|
<span className="text-base font-medium">{deployment.app.name}</span>
|
||||||
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
|
<span className="text-base font-medium">{deployment.release?.version || '빠른 배포'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
||||||
@@ -357,6 +384,60 @@ export function DeploymentManagementPage() {
|
|||||||
{deployableReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
{deployableReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} disabled={deploying} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} disabled={deploying} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Replicas</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={replicas}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setReplicas(event.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">CPU 요청</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={cpuRequest}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setCpuRequest(event.target.value)}
|
||||||
|
placeholder="예: 100m"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">CPU 상한</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={cpuLimit}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setCpuLimit(event.target.value)}
|
||||||
|
placeholder="예: 500m"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">메모리 요청</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={memoryRequest}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setMemoryRequest(event.target.value)}
|
||||||
|
placeholder="예: 256Mi"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">메모리 상한</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={memoryLimit}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setMemoryLimit(event.target.value)}
|
||||||
|
placeholder="예: 512Mi"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
|
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import type {
|
|||||||
} from '../../types/application';
|
} from '../../types/application';
|
||||||
import type { ApiAppComponent } from '../../types/runtime';
|
import type { ApiAppComponent } from '../../types/runtime';
|
||||||
|
|
||||||
|
export type DeploymentResourceLimits = {
|
||||||
|
replicas?: number;
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit?: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type GiteaCommit = {
|
export type GiteaCommit = {
|
||||||
sha: string;
|
sha: string;
|
||||||
commit?: {
|
commit?: {
|
||||||
@@ -230,7 +238,24 @@ export function listDeploymentsPage(options: { appKey?: string; page: number; li
|
|||||||
return apiRequest<{ deployments: DeploymentJob[]; total: number; page: number; limit: number; deployedReleaseIds?: string[] }>(`/apps/deployments?${params.toString()}`);
|
return apiRequest<{ deployments: DeploymentJob[]; total: number; page: number; limit: number; deployedReleaseIds?: string[] }>(`/apps/deployments?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDeployment(input: { releaseId: string; environment: string; namespace: string; updateLauncherAppVersion?: boolean }) {
|
export function createDeployment(input: {
|
||||||
|
releaseId: string;
|
||||||
|
environment: string;
|
||||||
|
namespace: string;
|
||||||
|
updateLauncherAppVersion?: boolean;
|
||||||
|
resources?: DeploymentResourceLimits;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQuickDeployment(input: {
|
||||||
|
buildJobId: string;
|
||||||
|
environment: string;
|
||||||
|
namespace: string;
|
||||||
|
}) {
|
||||||
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Plus, RotateCcw, Server, Settings, Trash2 } from 'lucide-react';
|
import { Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
@@ -8,7 +8,9 @@ import type { RuntimeResource } from '../../types/runtime';
|
|||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
loadLauncherRuntimeResources,
|
loadLauncherRuntimeResources,
|
||||||
|
loadLauncherComponentLogs,
|
||||||
loadRuntimeWorkloadEnv,
|
loadRuntimeWorkloadEnv,
|
||||||
|
restartLauncherComponent,
|
||||||
saveRuntimeWorkloadEnv
|
saveRuntimeWorkloadEnv
|
||||||
} from '../launcher/launcher-api';
|
} from '../launcher/launcher-api';
|
||||||
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
||||||
@@ -24,6 +26,10 @@ function apiKind(kind: string) {
|
|||||||
return kind.toLowerCase();
|
return kind.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripAnsi(value: string) {
|
||||||
|
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
function findResource(resources: RuntimeResource[], kind: string, name: string) {
|
function findResource(resources: RuntimeResource[], kind: string, name: string) {
|
||||||
return resources.find((resource) => resource.kind === kind && resource.name === name);
|
return resources.find((resource) => resource.kind === kind && resource.name === name);
|
||||||
}
|
}
|
||||||
@@ -53,10 +59,17 @@ export function CommonAppComponentsPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [logTarget, setLogTarget] = useState<CommonAppComponent | null>(null);
|
||||||
|
const [logs, setLogs] = useState('');
|
||||||
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [envTarget, setEnvTarget] = useState<CommonAppComponent | null>(null);
|
const [envTarget, setEnvTarget] = useState<CommonAppComponent | null>(null);
|
||||||
|
const [restartTarget, setRestartTarget] = useState<CommonAppComponent | null>(null);
|
||||||
|
const [restartKey, setRestartKey] = useState('');
|
||||||
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
||||||
const [envLoading, setEnvLoading] = useState(false);
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
const [envSaving, setEnvSaving] = useState(false);
|
const [envSaving, setEnvSaving] = useState(false);
|
||||||
|
const envTableBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const newEnvKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
const loadingVisible = useMinimumLoading(loading || envLoading);
|
const loadingVisible = useMinimumLoading(loading || envLoading);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
@@ -78,13 +91,25 @@ export function CommonAppComponentsPage() {
|
|||||||
setEnvVariables([]);
|
setEnvVariables([]);
|
||||||
setEnvLoading(true);
|
setEnvLoading(true);
|
||||||
loadRuntimeWorkloadEnv(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
loadRuntimeWorkloadEnv(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
.then((result) => setEnvVariables(result.variables ?? []))
|
.then((result) => {
|
||||||
|
setEnvVariables([...(result.variables ?? [])].sort((left, right) => left.key.localeCompare(right.key)));
|
||||||
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
||||||
})
|
})
|
||||||
.finally(() => setEnvLoading(false));
|
.finally(() => setEnvLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openLogs = (component: CommonAppComponent) => {
|
||||||
|
setLogTarget(component);
|
||||||
|
setLogs('');
|
||||||
|
setLogLoading(true);
|
||||||
|
loadLauncherComponentLogs(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
|
.then((result) => setLogs(stripAnsi(result.logs || '로그가 없습니다.')))
|
||||||
|
.catch((error: Error) => setLogs(`[warn] 로그 조회 실패\n${error.message}`))
|
||||||
|
.finally(() => setLogLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const updateEnvVariable = (index: number, patch: Partial<Pick<EnvVariable, 'key' | 'value' | 'isSecret'>>) => {
|
const updateEnvVariable = (index: number, patch: Partial<Pick<EnvVariable, 'key' | 'value' | 'isSecret'>>) => {
|
||||||
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
||||||
variableIndex === index ? { ...variable, ...patch } : variable
|
variableIndex === index ? { ...variable, ...patch } : variable
|
||||||
@@ -95,6 +120,27 @@ export function CommonAppComponentsPage() {
|
|||||||
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addEnvVariable = () => {
|
||||||
|
setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }]);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
envTableBodyRef.current?.scrollTo({ top: envTableBodyRef.current.scrollHeight, behavior: 'smooth' });
|
||||||
|
newEnvKeyInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = (component: CommonAppComponent) => {
|
||||||
|
setRestartKey(component.key);
|
||||||
|
restartLauncherComponent(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', message: `${component.name} 재시작을 요청했습니다.` });
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '재시작 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setRestartKey(''));
|
||||||
|
};
|
||||||
|
|
||||||
const saveEnv = () => {
|
const saveEnv = () => {
|
||||||
if (!envTarget) return;
|
if (!envTarget) return;
|
||||||
setEnvSaving(true);
|
setEnvSaving(true);
|
||||||
@@ -173,7 +219,15 @@ export function CommonAppComponentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={() => openLogs(component)} disabled={!workload}>
|
||||||
|
<TerminalSquare className="size-4" />
|
||||||
|
로그
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setRestartTarget(component)} disabled={!workload || restartKey === component.key}>
|
||||||
|
<RotateCcw className={cn('size-4', restartKey === component.key && 'animate-spin')} />
|
||||||
|
재시작
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
설정값
|
설정값
|
||||||
@@ -218,13 +272,59 @@ export function CommonAppComponentsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(logTarget)}
|
||||||
|
title={logTarget ? `${logTarget.name} 로그` : '로그'}
|
||||||
|
onClose={() => setLogTarget(null)}
|
||||||
|
className="!max-w-5xl"
|
||||||
|
>
|
||||||
|
<pre className="max-h-[62vh] overflow-auto rounded-md bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
||||||
|
{logs || (logLoading ? '로그를 불러오는 중...' : '로그가 없습니다.')}
|
||||||
|
</pre>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(restartTarget)}
|
||||||
|
title={restartTarget ? `${restartTarget.name} 재시작` : '구성요소 재시작'}
|
||||||
|
onClose={() => {
|
||||||
|
if (!restartTarget || restartKey !== restartTarget.key) setRestartTarget(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setRestartTarget(null)} disabled={Boolean(restartTarget && restartKey === restartTarget.key)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!restartTarget) return;
|
||||||
|
restart(restartTarget);
|
||||||
|
setRestartTarget(null);
|
||||||
|
}}
|
||||||
|
disabled={Boolean(restartTarget && restartKey === restartTarget.key)}
|
||||||
|
>
|
||||||
|
재시작
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
{restartTarget?.name}을 재시작합니다. 실행 중인 요청은 잠시 영향을 받을 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(envTarget)}
|
open={Boolean(envTarget)}
|
||||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||||
onClose={() => setEnvTarget(null)}
|
onClose={() => setEnvTarget(null)}
|
||||||
className="!max-w-4xl"
|
className="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col"
|
||||||
|
bodyClassName="!flex !min-h-0 !max-h-none !flex-1 !overflow-hidden"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
<Button variant="outline" onClick={addEnvVariable} disabled={envLoading}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
설정값 추가
|
||||||
|
</Button>
|
||||||
|
<span className="flex-1" />
|
||||||
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
||||||
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
||||||
{envSaving ? '저장 중' : '저장'}
|
{envSaving ? '저장 중' : '저장'}
|
||||||
@@ -232,8 +332,8 @@ export function CommonAppComponentsPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="rounded-md border border-border">
|
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border">
|
||||||
<table className="w-full table-fixed text-left text-sm">
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[26%]" />
|
<col className="w-[26%]" />
|
||||||
@@ -266,6 +366,7 @@ export function CommonAppComponentsPage() {
|
|||||||
<tr key={`${variable.key}-${index}`}>
|
<tr key={`${variable.key}-${index}`}>
|
||||||
<td className="min-w-0 px-3 py-2.5">
|
<td className="min-w-0 px-3 py-2.5">
|
||||||
<input
|
<input
|
||||||
|
ref={index === envVariables.length - 1 && variable.editable ? newEnvKeyInputRef : undefined}
|
||||||
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
||||||
value={variable.key}
|
value={variable.key}
|
||||||
disabled={!variable.editable}
|
disabled={!variable.editable}
|
||||||
@@ -312,14 +413,6 @@ export function CommonAppComponentsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }])}
|
|
||||||
disabled={envLoading}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
설정값 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { RotateCcw } from 'lucide-react';
|
import { FileCode2, RotateCcw } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { runtimeStatusDotClass } from '../../lib/status-dot';
|
import { runtimeStatusDotClass } from '../../lib/status-dot';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import type { RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
import type { RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||||
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources } from '../runtime/runtime-api';
|
import { loadApps, loadRuntimePodMetrics, loadRuntimeResourceYaml, loadRuntimeResources } from '../runtime/runtime-api';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
|
||||||
const defaultNamespaces = ['studio-system', 'stdo-lms', 'stdo-ads-ide'];
|
const defaultNamespaces = ['studio-system', 'stdo-lms', 'stdo-ads-ide'];
|
||||||
@@ -40,7 +40,7 @@ function isNetwork(resource: RuntimeResource) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isStorage(resource: RuntimeResource) {
|
function isStorage(resource: RuntimeResource) {
|
||||||
return ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass'].includes(resource.kind);
|
return ['PersistentVolumeClaim', 'PVC', 'PersistentVolume', 'StorageClass'].includes(resource.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourceStatusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
function resourceStatusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||||
@@ -191,6 +191,22 @@ function ResourceTable({
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [yamlTarget, setYamlTarget] = useState<ResourceRow | null>(null);
|
||||||
|
const [yaml, setYaml] = useState('');
|
||||||
|
const [yamlLoading, setYamlLoading] = useState(false);
|
||||||
|
const [yamlError, setYamlError] = useState('');
|
||||||
|
|
||||||
|
const openYaml = (row: ResourceRow) => {
|
||||||
|
setYamlTarget(row);
|
||||||
|
setYaml('');
|
||||||
|
setYamlError('');
|
||||||
|
setYamlLoading(true);
|
||||||
|
loadRuntimeResourceYaml(row.namespace, row.kind, row.name)
|
||||||
|
.then((response) => setYaml(response.yaml))
|
||||||
|
.catch((error: unknown) => setYamlError(error instanceof Error ? error.message : 'YAML을 불러오지 못했습니다.'))
|
||||||
|
.finally(() => setYamlLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<ResourceRow>>>(
|
const columns = useMemo<Array<DataTableColumn<ResourceRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'namespace', header: '네임스페이스', className: 'w-44 px-5 py-3', cell: (row) => <span className="font-medium">{row.namespace}</span> },
|
{ key: 'namespace', header: '네임스페이스', className: 'w-44 px-5 py-3', cell: (row) => <span className="font-medium">{row.namespace}</span> },
|
||||||
@@ -201,7 +217,23 @@ function ResourceTable({
|
|||||||
{ key: 'restarts', header: '재시작', className: 'w-24 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.restarts ?? '-'}</span> },
|
{ key: 'restarts', header: '재시작', className: 'w-24 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.restarts ?? '-'}</span> },
|
||||||
{ key: 'metric', header: '사용량', className: 'w-40 px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.metric ? `${row.metric.cpu} / ${row.metric.memory}` : '-'}</span> },
|
{ key: 'metric', header: '사용량', className: 'w-40 px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.metric ? `${row.metric.cpu} / ${row.metric.memory}` : '-'}</span> },
|
||||||
{ key: 'age', header: 'Age', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.age || '-'}</span> },
|
{ key: 'age', header: 'Age', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.age || '-'}</span> },
|
||||||
{ key: 'detail', header: '상세', className: 'min-w-[260px] px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.detail || '-'}</span> }
|
{ key: 'detail', header: '상세', className: 'min-w-[220px] px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.detail || '-'}</span> },
|
||||||
|
{
|
||||||
|
key: 'yaml',
|
||||||
|
header: '',
|
||||||
|
className: 'w-14 px-3 py-3 text-right',
|
||||||
|
cell: (row) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="YAML 보기"
|
||||||
|
aria-label="YAML 보기"
|
||||||
|
onClick={() => openYaml(row)}
|
||||||
|
>
|
||||||
|
<FileCode2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -209,20 +241,34 @@ function ResourceTable({
|
|||||||
const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]);
|
const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTableCard
|
<>
|
||||||
title={title}
|
<DataTableCard
|
||||||
description={description}
|
title={title}
|
||||||
rows={rows}
|
description={description}
|
||||||
columns={columns}
|
rows={rows}
|
||||||
filters={filters}
|
columns={columns}
|
||||||
searchPlaceholder="네임스페이스, 이름, 상태 검색"
|
filters={filters}
|
||||||
getSearchText={(row) => `${row.namespace} ${row.kind} ${row.name} ${row.status} ${row.detail}`}
|
searchPlaceholder="네임스페이스, 이름, 상태 검색"
|
||||||
getRowKey={(row) => row.id}
|
getSearchText={(row) => `${row.namespace} ${row.kind} ${row.name} ${row.status} ${row.detail}`}
|
||||||
pageSize={10}
|
getRowKey={(row) => row.id}
|
||||||
minWidth={1280}
|
pageSize={10}
|
||||||
loading={loading}
|
minWidth={1320}
|
||||||
emptyMessage={emptyMessage}
|
loading={loading}
|
||||||
/>
|
emptyMessage={emptyMessage}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(yamlTarget)}
|
||||||
|
title={yamlTarget ? `${yamlTarget.kind}/${yamlTarget.name}` : 'YAML'}
|
||||||
|
onClose={() => setYamlTarget(null)}
|
||||||
|
className="!max-w-5xl"
|
||||||
|
>
|
||||||
|
{yamlLoading ? <p className="py-10 text-center text-sm text-muted-foreground">불러오는 중...</p> : null}
|
||||||
|
{yamlError ? <p className="py-4 text-sm text-rose-300">{yamlError}</p> : null}
|
||||||
|
{!yamlLoading && !yamlError ? (
|
||||||
|
<pre className="max-h-[65vh] overflow-auto rounded-md border border-border bg-black/30 p-4 text-xs leading-6 text-slate-200">{yaml}</pre>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
@@ -7,6 +7,8 @@ import { formatDateTime } from '../../lib/date-format';
|
|||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
createLauncherBuildJob,
|
createLauncherBuildJob,
|
||||||
|
deployLauncherBackend,
|
||||||
|
getLauncherBackendDeployment,
|
||||||
getLauncherSource,
|
getLauncherSource,
|
||||||
listLauncherBuildEnv,
|
listLauncherBuildEnv,
|
||||||
listLauncherBuildJobs,
|
listLauncherBuildJobs,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
type LauncherSource,
|
type LauncherSource,
|
||||||
type LauncherBuildJob,
|
type LauncherBuildJob,
|
||||||
type LauncherBuildEnvVariable,
|
type LauncherBuildEnvVariable,
|
||||||
|
type LauncherBackendDeployment,
|
||||||
type LauncherVersion
|
type LauncherVersion
|
||||||
} from './launcher-api';
|
} from './launcher-api';
|
||||||
|
|
||||||
@@ -132,6 +135,9 @@ export function LauncherBuildPage() {
|
|||||||
const [buildEnv, setBuildEnv] = useState<LauncherBuildEnvVariable[]>([]);
|
const [buildEnv, setBuildEnv] = useState<LauncherBuildEnvVariable[]>([]);
|
||||||
const [buildEnvSaving, setBuildEnvSaving] = useState(false);
|
const [buildEnvSaving, setBuildEnvSaving] = useState(false);
|
||||||
const [selectedJob, setSelectedJob] = useState<LauncherBuildJob | null>(null);
|
const [selectedJob, setSelectedJob] = useState<LauncherBuildJob | null>(null);
|
||||||
|
const [quickDeployJob, setQuickDeployJob] = useState<LauncherBuildJob | null>(null);
|
||||||
|
const [backendDeployment, setBackendDeployment] = useState<LauncherBackendDeployment | null>(null);
|
||||||
|
const [quickDeploying, setQuickDeploying] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -153,6 +159,13 @@ export function LauncherBuildPage() {
|
|||||||
const pagedJobs = jobs;
|
const pagedJobs = jobs;
|
||||||
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
||||||
|
|
||||||
|
const isQuickDeployable = (job: LauncherBuildJob) => (
|
||||||
|
target === 'launcher-backend'
|
||||||
|
&& job.target === 'launcher-backend'
|
||||||
|
&& job.status === 'SUCCEEDED'
|
||||||
|
&& Boolean(job.downloadUrl)
|
||||||
|
);
|
||||||
|
|
||||||
const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => (
|
const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => (
|
||||||
listLauncherBuildJobsPage({
|
listLauncherBuildJobsPage({
|
||||||
target: nextTarget,
|
target: nextTarget,
|
||||||
@@ -269,6 +282,31 @@ export function LauncherBuildPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openQuickDeploy = (job: LauncherBuildJob) => {
|
||||||
|
setQuickDeployJob(job);
|
||||||
|
setBackendDeployment(null);
|
||||||
|
getLauncherBackendDeployment()
|
||||||
|
.then(setBackendDeployment)
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '배포 대상 조회 실패', message: error.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickDeploy = () => {
|
||||||
|
if (!quickDeployJob?.downloadUrl) return;
|
||||||
|
setQuickDeploying(true);
|
||||||
|
deployLauncherBackend(quickDeployJob.downloadUrl)
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
||||||
|
setQuickDeployJob(null);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '백엔드 배포 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setQuickDeploying(false));
|
||||||
|
};
|
||||||
|
|
||||||
const updateBuildEnv = (index: number, patch: Partial<LauncherBuildEnvVariable>) => {
|
const updateBuildEnv = (index: number, patch: Partial<LauncherBuildEnvVariable>) => {
|
||||||
setBuildEnv((prev) => prev.map((variable, variableIndex) => (
|
setBuildEnv((prev) => prev.map((variable, variableIndex) => (
|
||||||
variableIndex === index ? { ...variable, ...patch } : variable
|
variableIndex === index ? { ...variable, ...patch } : variable
|
||||||
@@ -440,9 +478,22 @@ export function LauncherBuildPage() {
|
|||||||
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<MoreHorizontal className="size-4" />
|
{isQuickDeployable(job) ? (
|
||||||
</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="빠른 배포"
|
||||||
|
aria-label="빠른 배포"
|
||||||
|
onClick={() => openQuickDeploy(job)}
|
||||||
|
>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -500,6 +551,51 @@ export function LauncherBuildPage() {
|
|||||||
{selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
|
{selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
|
||||||
</pre>
|
</pre>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(quickDeployJob)}
|
||||||
|
title="빠른 배포"
|
||||||
|
onClose={() => !quickDeploying && setQuickDeployJob(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setQuickDeployJob(null)} disabled={quickDeploying}>취소</Button>
|
||||||
|
<Button onClick={quickDeploy} disabled={quickDeploying || !quickDeployJob?.downloadUrl}>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
{quickDeploying ? '배포 중' : '배포'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||||
|
<p className="font-medium">성공한 백엔드 빌드 이미지를 바로 배포합니다.</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
현재 설정된 복제 수와 CPU, 메모리 제한은 변경하지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 rounded-md border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">대상</span>
|
||||||
|
<span className="font-mono text-xs font-medium">
|
||||||
|
{backendDeployment ? `${backendDeployment.namespace}/deployment/${backendDeployment.deployment}` : '배포 대상 확인 중'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">빌드</span>
|
||||||
|
<span className="font-medium">{quickDeployJob?.version || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1 pt-1">
|
||||||
|
<span className="text-muted-foreground">이미지</span>
|
||||||
|
<span className="break-all font-mono text-xs font-medium">{quickDeployJob?.downloadUrl || '-'}</span>
|
||||||
|
</div>
|
||||||
|
{backendDeployment?.currentImage ? (
|
||||||
|
<div className="grid gap-1 border-t border-border pt-3">
|
||||||
|
<span className="text-muted-foreground">현재 이미지</span>
|
||||||
|
<span className="break-all font-mono text-xs text-muted-foreground">{backendDeployment.currentImage}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
title="빌드 실행 확인"
|
title="빌드 실행 확인"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Database, FileArchive, Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
import { Database, FileArchive, Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
@@ -139,9 +139,12 @@ export function LauncherComponentsPage() {
|
|||||||
const [logs, setLogs] = useState('');
|
const [logs, setLogs] = useState('');
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
|
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
|
||||||
|
const [restartTarget, setRestartTarget] = useState<LauncherComponentDefinition | null>(null);
|
||||||
const [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; isSecret: boolean; editable: boolean; source: string }>>([]);
|
const [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; isSecret: boolean; editable: boolean; source: string }>>([]);
|
||||||
const [envLoading, setEnvLoading] = useState(false);
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
const [envSaving, setEnvSaving] = useState(false);
|
const [envSaving, setEnvSaving] = useState(false);
|
||||||
|
const envTableBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const newEnvKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
const loadingVisible = useMinimumLoading(loading || logLoading);
|
const loadingVisible = useMinimumLoading(loading || logLoading);
|
||||||
|
|
||||||
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
||||||
@@ -201,7 +204,7 @@ export function LauncherComponentsPage() {
|
|||||||
setEnvLoading(true);
|
setEnvLoading(true);
|
||||||
loadRuntimeWorkloadEnv(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName)
|
loadRuntimeWorkloadEnv(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setEnvVariables(result.variables ?? []);
|
setEnvVariables([...(result.variables ?? [])].sort((left, right) => left.key.localeCompare(right.key)));
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
||||||
@@ -219,6 +222,14 @@ export function LauncherComponentsPage() {
|
|||||||
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addEnvVariable = () => {
|
||||||
|
setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }]);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
envTableBodyRef.current?.scrollTo({ top: envTableBodyRef.current.scrollHeight, behavior: 'smooth' });
|
||||||
|
newEnvKeyInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const saveEnv = () => {
|
const saveEnv = () => {
|
||||||
if (!envTarget?.kind || !envTarget.workloadName) return;
|
if (!envTarget?.kind || !envTarget.workloadName) return;
|
||||||
setEnvSaving(true);
|
setEnvSaving(true);
|
||||||
@@ -341,7 +352,7 @@ export function LauncherComponentsPage() {
|
|||||||
<TerminalSquare className="size-4" />
|
<TerminalSquare className="size-4" />
|
||||||
로그
|
로그
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => restart(component)} disabled={!workload || actionKey === component.key}>
|
<Button onClick={() => setRestartTarget(component)} disabled={!workload || actionKey === component.key}>
|
||||||
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
||||||
재시작
|
재시작
|
||||||
</Button>
|
</Button>
|
||||||
@@ -413,13 +424,48 @@ export function LauncherComponentsPage() {
|
|||||||
</pre>
|
</pre>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(restartTarget)}
|
||||||
|
title={restartTarget ? `${restartTarget.name} 재시작` : '구성요소 재시작'}
|
||||||
|
onClose={() => {
|
||||||
|
if (!restartTarget || actionKey !== restartTarget.key) setRestartTarget(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setRestartTarget(null)} disabled={Boolean(restartTarget && actionKey === restartTarget.key)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!restartTarget) return;
|
||||||
|
restart(restartTarget);
|
||||||
|
setRestartTarget(null);
|
||||||
|
}}
|
||||||
|
disabled={Boolean(restartTarget && actionKey === restartTarget.key)}
|
||||||
|
>
|
||||||
|
재시작
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
{restartTarget?.name}을 재시작합니다. 실행 중인 요청은 잠시 영향을 받을 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(envTarget)}
|
open={Boolean(envTarget)}
|
||||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||||
onClose={() => setEnvTarget(null)}
|
onClose={() => setEnvTarget(null)}
|
||||||
className="!max-w-4xl"
|
className="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col"
|
||||||
|
bodyClassName="!flex !min-h-0 !max-h-none !flex-1 !overflow-hidden"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
<Button variant="outline" onClick={addEnvVariable} disabled={envLoading}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
설정값 추가
|
||||||
|
</Button>
|
||||||
|
<span className="flex-1" />
|
||||||
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
||||||
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
||||||
{envSaving ? '저장 중' : '저장'}
|
{envSaving ? '저장 중' : '저장'}
|
||||||
@@ -427,11 +473,11 @@ export function LauncherComponentsPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
Secret을 체크하면 Kubernetes Secret에 저장하고 secretKeyRef로 연결합니다. 체크하지 않은 값은 Deployment 환경변수로 저장합니다.
|
Secret을 체크하면 Kubernetes Secret에 저장하고 secretKeyRef로 연결합니다. 체크하지 않은 값은 Deployment 환경변수로 저장합니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-md border border-border">
|
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border">
|
||||||
<table className="w-full table-fixed text-left text-sm">
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[26%]" />
|
<col className="w-[26%]" />
|
||||||
@@ -464,6 +510,7 @@ export function LauncherComponentsPage() {
|
|||||||
<tr key={`${variable.key}-${index}`}>
|
<tr key={`${variable.key}-${index}`}>
|
||||||
<td className="min-w-0 px-3 py-2.5">
|
<td className="min-w-0 px-3 py-2.5">
|
||||||
<input
|
<input
|
||||||
|
ref={index === envVariables.length - 1 && variable.editable ? newEnvKeyInputRef : undefined}
|
||||||
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
||||||
value={variable.key}
|
value={variable.key}
|
||||||
disabled={!variable.editable}
|
disabled={!variable.editable}
|
||||||
@@ -510,14 +557,6 @@ export function LauncherComponentsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }])}
|
|
||||||
disabled={envLoading}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
설정값 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export function LauncherVersionsPage() {
|
|||||||
const [deployOpen, setDeployOpen] = useState(false);
|
const [deployOpen, setDeployOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
|
const [deployReplicas, setDeployReplicas] = useState('1');
|
||||||
|
const [deployCpuRequest, setDeployCpuRequest] = useState('100m');
|
||||||
|
const [deployCpuLimit, setDeployCpuLimit] = useState('500m');
|
||||||
|
const [deployMemoryRequest, setDeployMemoryRequest] = useState('128Mi');
|
||||||
|
const [deployMemoryLimit, setDeployMemoryLimit] = useState('256Mi');
|
||||||
const [releaseForm, setReleaseForm] = useState({
|
const [releaseForm, setReleaseForm] = useState({
|
||||||
buildJobId: '',
|
buildJobId: '',
|
||||||
version: '',
|
version: '',
|
||||||
@@ -128,7 +133,16 @@ export function LauncherVersionsPage() {
|
|||||||
const deployBackend = () => {
|
const deployBackend = () => {
|
||||||
if (!latestBackendBuild?.downloadUrl) return;
|
if (!latestBackendBuild?.downloadUrl) return;
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
deployLauncherBackend(latestBackendBuild.downloadUrl)
|
const requestedReplicas = Number.parseInt(deployReplicas, 10);
|
||||||
|
const resources = {
|
||||||
|
...(Number.isFinite(requestedReplicas) && requestedReplicas > 0 ? { replicas: requestedReplicas } : {}),
|
||||||
|
...(deployCpuRequest.trim() ? { cpuRequest: deployCpuRequest.trim() } : {}),
|
||||||
|
...(deployCpuLimit.trim() ? { cpuLimit: deployCpuLimit.trim() } : {}),
|
||||||
|
...(deployMemoryRequest.trim() ? { memoryRequest: deployMemoryRequest.trim() } : {}),
|
||||||
|
...(deployMemoryLimit.trim() ? { memoryLimit: deployMemoryLimit.trim() } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
deployLauncherBackend(latestBackendBuild.downloadUrl, Object.keys(resources).length ? resources : undefined)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setDeployResult(result);
|
setDeployResult(result);
|
||||||
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
||||||
@@ -141,6 +155,15 @@ export function LauncherVersionsPage() {
|
|||||||
.finally(() => setDeploying(false));
|
.finally(() => setDeploying(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openBackendDeploy = () => {
|
||||||
|
setDeployReplicas('1');
|
||||||
|
setDeployCpuRequest('100m');
|
||||||
|
setDeployCpuLimit('500m');
|
||||||
|
setDeployMemoryRequest('128Mi');
|
||||||
|
setDeployMemoryLimit('256Mi');
|
||||||
|
setDeployOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -159,7 +182,7 @@ export function LauncherVersionsPage() {
|
|||||||
릴리즈 등록
|
릴리즈 등록
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setDeployOpen(true)} disabled={!latestBackendBuild?.downloadUrl}>
|
<Button className="h-8 px-3 text-[13px]" onClick={openBackendDeploy} disabled={!latestBackendBuild?.downloadUrl}>
|
||||||
<Play className="size-4" />
|
<Play className="size-4" />
|
||||||
백엔드 배포
|
백엔드 배포
|
||||||
</Button>
|
</Button>
|
||||||
@@ -345,6 +368,60 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="font-medium">최신 성공 빌드 이미지로 런처 백엔드를 배포합니다.</p>
|
<p className="font-medium">최신 성공 빌드 이미지로 런처 백엔드를 배포합니다.</p>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">Deployment 이미지가 교체되고 rollout 완료까지 대기합니다.</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">Deployment 이미지가 교체되고 rollout 완료까지 대기합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Replicas</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployReplicas}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployReplicas(event.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">CPU 요청</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployCpuRequest}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployCpuRequest(event.target.value)}
|
||||||
|
placeholder="예: 100m"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">CPU 상한</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployCpuLimit}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployCpuLimit(event.target.value)}
|
||||||
|
placeholder="예: 500m"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">메모리 요청</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployMemoryRequest}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployMemoryRequest(event.target.value)}
|
||||||
|
placeholder="예: 256Mi"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">메모리 상한</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployMemoryLimit}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployMemoryLimit(event.target.value)}
|
||||||
|
placeholder="예: 512Mi"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<div className="rounded-md border border-border bg-background p-4">
|
<div className="rounded-md border border-border bg-background p-4">
|
||||||
<p className="text-xs text-muted-foreground">배포 이미지</p>
|
<p className="text-xs text-muted-foreground">배포 이미지</p>
|
||||||
<p className="mt-2 break-all font-mono text-xs font-medium">{latestBackendBuild?.downloadUrl || '-'}</p>
|
<p className="mt-2 break-all font-mono text-xs font-medium">{latestBackendBuild?.downloadUrl || '-'}</p>
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ export type LauncherBackendDeployResult = {
|
|||||||
logs: string;
|
logs: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeploymentResourceLimits = {
|
||||||
|
replicas?: number;
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit?: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type LauncherBuildEnvVariable = {
|
export type LauncherBuildEnvVariable = {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -281,10 +289,10 @@ export function getLauncherBackendDeployment() {
|
|||||||
return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment');
|
return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deployLauncherBackend(image: string) {
|
export function deployLauncherBackend(image: string, resources?: DeploymentResourceLimits) {
|
||||||
return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', {
|
return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ image })
|
body: JSON.stringify({ image, resources: resources && Object.keys(resources).length ? resources : undefined })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,8 +180,12 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
const submitAdmin = () => {
|
const submitAdmin = () => {
|
||||||
if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) return;
|
if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) return;
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(adminForm.email.trim())) {
|
||||||
|
toast({ type: 'error', title: '관리자 등록 실패', message: '올바른 이메일 주소를 입력해주세요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
createAdmin(adminForm)
|
createAdmin({ ...adminForm, email: adminForm.email.trim().toLowerCase() })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSaveState('idle');
|
setSaveState('idle');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
@@ -448,7 +452,7 @@ export function AdminManagementPage() {
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.email} onChange={(event) => setAdminForm((prev) => ({ ...prev, email: event.target.value }))} />
|
<input type="email" 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={adminForm.email} onChange={(event) => setAdminForm((prev) => ({ ...prev, email: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이름 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">이름 <b className="text-foreground">*</b></span>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn } from '../../components/data-table';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { Button, Modal } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
|
import { AppSelector, useApps } from '../applications/ApplicationControls';
|
||||||
|
import {
|
||||||
|
createManualDocument,
|
||||||
|
deleteManualDocument,
|
||||||
|
loadManualDocumentsPage,
|
||||||
|
type ManualDocument,
|
||||||
|
updateManualDocument,
|
||||||
|
} from './operations-api';
|
||||||
|
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
|
|
||||||
|
type ManualForm = Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>;
|
||||||
|
|
||||||
|
const emptyForm: ManualForm = { appKey: '', title: '', body: '', isPublished: true, sortOrder: 0 };
|
||||||
|
|
||||||
|
function ManualCreator({ document }: { document: ManualDocument }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = document.createdByName || document.createdByEmail || '-';
|
||||||
|
const avatarUrl = document.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img src={avatarUrl} alt="" className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover" onError={() => setFailed(true)} />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">{initial}</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||||
|
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(document.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManualsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [documents, setDocuments] = useState<ManualDocument[]>([]);
|
||||||
|
const { apps, selectedApp, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||||
|
const selectedAppKey = selectedApp?.key ?? '';
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ManualDocument | null>(null);
|
||||||
|
const [form, setForm] = useState<ManualForm>(emptyForm);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ManualDocument | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
if (!selectedAppKey) {
|
||||||
|
setDocuments([]);
|
||||||
|
setTotal(0);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
loadManualDocumentsPage({ page, limit: 6, keyword, appKey: selectedAppKey })
|
||||||
|
.then((result) => { setDocuments(result.items); setTotal(result.total); setFailed(false); })
|
||||||
|
.catch((error: Error) => { setDocuments([]); setTotal(0); setFailed(true); toast({ type: 'error', title: '매뉴얼 조회 실패', message: error.message }); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [page, keyword, selectedAppKey]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const close = () => setMenu(null);
|
||||||
|
window.addEventListener('click', close);
|
||||||
|
return () => window.removeEventListener('click', close);
|
||||||
|
}, [menu]);
|
||||||
|
|
||||||
|
const openCreate = () => { setEditing(null); setForm({ ...emptyForm, appKey: selectedAppKey }); setModalOpen(true); };
|
||||||
|
const openEdit = (document: ManualDocument) => {
|
||||||
|
setMenu(null);
|
||||||
|
setEditing(document);
|
||||||
|
setForm({ appKey: document.appKey, title: document.title, body: document.body, isPublished: document.isPublished, sortOrder: document.sortOrder });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
const closeModal = () => { setEditing(null); setForm(emptyForm); setModalOpen(false); };
|
||||||
|
const submit = () => {
|
||||||
|
if (!form.appKey.trim() || !form.title.trim() || !form.body.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const payload = { ...form, appKey: form.appKey.trim(), title: form.title.trim(), body: form.body.trim() };
|
||||||
|
const request = editing ? updateManualDocument(editing.id, payload) : createManualDocument(payload);
|
||||||
|
request
|
||||||
|
.then(() => { toast({ type: 'success', message: editing ? '매뉴얼을 수정했습니다.' : '매뉴얼을 추가했습니다.' }); closeModal(); refresh(); })
|
||||||
|
.catch((error: Error) => toast({ type: 'error', title: editing ? '매뉴얼 수정 실패' : '매뉴얼 추가 실패', message: error.message }))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setSaving(true);
|
||||||
|
deleteManualDocument(deleteTarget.id)
|
||||||
|
.then(() => { toast({ type: 'success', message: '매뉴얼을 삭제했습니다.' }); setDeleteTarget(null); refresh(); })
|
||||||
|
.catch((error: Error) => toast({ type: 'error', title: '매뉴얼 삭제 실패', message: error.message }))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<ManualDocument>>>(() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
|
{ key: 'title', header: '문서명', width: 220, className: 'px-5 py-3 font-medium', cell: (row) => row.title },
|
||||||
|
{ key: 'body', header: '본문', flexible: true, className: 'px-5 py-3', cell: (row) => <p className="truncate">{row.body}</p> },
|
||||||
|
{ key: 'published', header: '공개', width: 100, className: 'px-5 py-3', cell: (row) => <StatusBadge status={row.isPublished ? '공개' : '비공개'} tone={row.isPublished ? 'green' : 'neutral'} /> },
|
||||||
|
{ key: 'sortOrder', header: '순서', width: 80, className: 'px-5 py-3 text-center', cell: (row) => row.sortOrder },
|
||||||
|
{ key: 'createdAt', header: '생성', width: 190, className: 'px-5 py-3', cell: (row) => <ManualCreator document={row} /> },
|
||||||
|
{ key: 'actions', header: '', className: 'px-5 py-3', cell: (row) => <div className="relative flex justify-end"><Button variant="ghost" className="size-8 px-0" title="메뉴" aria-label="메뉴" onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); setMenu((previous) => previous?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }); }}><MoreHorizontal className="size-4" /></Button></div> },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<PageTitle eyebrow="운영" title="매뉴얼" description="런처 사용 매뉴얼의 Markdown 문서를 관리합니다." actions={<Button className="h-8 px-3 text-[13px]" onClick={openCreate} disabled={!selectedAppKey}><Plus className="size-4" />추가</Button>} />
|
||||||
|
{failed && <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>}
|
||||||
|
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={(appId) => { selectApp(appId); setPage(1); }} />
|
||||||
|
<DataTableCard title="목록" rows={documents} columns={columns} searchPlaceholder="문서명, 본문 검색" getSearchText={(row) => `${row.title} ${row.body}`} getRowKey={(row) => row.id} pageSize={6} minWidth={980} loading={loading} emptyMessage="등록된 매뉴얼이 없습니다." serverPagination={{ page, total, onPageChange: setPage, onSearchChange: (value) => { setKeyword(value); setPage(1); } }} />
|
||||||
|
{menu && (() => {
|
||||||
|
const document = documents.find((item) => item.id === menu.id);
|
||||||
|
if (!document) return null;
|
||||||
|
return <div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menu.x, top: menu.y, transform: 'translateX(-100%)' }}>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" onClick={() => openEdit(document)}>수정</button>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10" onClick={() => { setMenu(null); setDeleteTarget(document); }}>삭제</button>
|
||||||
|
</div>;
|
||||||
|
})()}
|
||||||
|
<Modal open={modalOpen} title={<span className="inline-flex items-center gap-2"><Pencil className="size-4 text-primary" />{editing ? '매뉴얼 수정' : '매뉴얼 추가'}</span>} onClose={closeModal} className="!max-w-3xl" footer={<><Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button><Button onClick={submit} disabled={saving || !form.appKey.trim() || !form.title.trim() || !form.body.trim()}>{saving ? '저장 중' : '저장'}</Button></>}>
|
||||||
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_96px]">
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">문서명 <b className="text-foreground">*</b></span><input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} /></label>
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">순서</span><input type="number" min="0" 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={form.sortOrder} onChange={(event) => setForm((prev) => ({ ...prev, sortOrder: Number(event.target.value) || 0 }))} /></label>
|
||||||
|
</div>
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span><textarea className="mt-2 min-h-72 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} /></label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-foreground"><input type="checkbox" checked={form.isPublished} onChange={(event) => setForm((prev) => ({ ...prev, isPublished: event.target.checked }))} />공개</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
<Modal open={Boolean(deleteTarget)} title={<span className="inline-flex items-center gap-2"><Trash2 className="size-4 text-rose-300" />매뉴얼 삭제</span>} onClose={() => setDeleteTarget(null)} footer={<><Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button><Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmDelete} disabled={saving}>{saving ? '삭제 중' : '삭제'}</Button></>}><p className="pb-6 text-sm text-foreground">매뉴얼 문서를 삭제할까요?</p></Modal>
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -36,6 +36,27 @@ function organizationTypeLabel(type: OrganizationType) {
|
|||||||
return organizationTypes.find((item) => item.value === type)?.label ?? type;
|
return organizationTypes.find((item) => item.value === type)?.label ?? type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OrganizationCreator({ organization }: { organization: Organization }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = organization.createdByName || organization.createdByEmail || '-';
|
||||||
|
const avatarUrl = organization.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img src={avatarUrl} alt="" className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover" onError={() => setFailed(true)} />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">{initial}</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||||
|
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(organization.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function OrganizationsPage() {
|
export function OrganizationsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
@@ -115,8 +136,9 @@ export function OrganizationsPage() {
|
|||||||
},
|
},
|
||||||
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
||||||
{ key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? <StatusBadge status={row.defaultLicenseName ?? row.defaultLicenseCode} tone="green" /> : <span className="text-xs text-muted-foreground">-</span> },
|
{ key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? <StatusBadge status={row.defaultLicenseName ?? row.defaultLicenseCode} tone="green" /> : <span className="text-xs text-muted-foreground">-</span> },
|
||||||
|
{ key: 'instructorInviteCode', header: '강사 가입 코드', className: 'w-40 px-5 py-3', cell: (row) => <span className="font-mono text-sm font-semibold tracking-[0.08em] text-foreground">{row.instructorInviteCode}</span> },
|
||||||
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
||||||
{ key: 'createdAt', header: '등록일', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.createdAt)}</span> },
|
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <OrganizationCreator organization={row} /> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { Button, Modal } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
|
import {
|
||||||
|
createTerm,
|
||||||
|
deleteTerm,
|
||||||
|
loadTermsPage,
|
||||||
|
updateTerm,
|
||||||
|
type LauncherTerm
|
||||||
|
} from './operations-api';
|
||||||
|
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
|
|
||||||
|
type TermForm = {
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const termKeyOptions = [
|
||||||
|
{ value: 'terms', label: '이용약관' },
|
||||||
|
{ value: 'privacy', label: '개인정보 처리방침' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyForm: TermForm = {
|
||||||
|
key: 'terms',
|
||||||
|
body: '',
|
||||||
|
isRequired: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function termKeyLabel(key: string) {
|
||||||
|
return termKeyOptions.find((option) => option.value === key)?.label ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function termKeyTone(key: string) {
|
||||||
|
return key === 'privacy' ? 'blue' as const : 'violet' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TermCreator({ term }: { term: LauncherTerm }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = term.createdByName || term.createdByEmail || '-';
|
||||||
|
const avatarUrl = term.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||||
|
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(term.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(term: LauncherTerm): TermForm {
|
||||||
|
return {
|
||||||
|
key: term.key,
|
||||||
|
body: term.body,
|
||||||
|
isRequired: term.isRequired
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TermsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [terms, setTerms] = useState<LauncherTerm[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<LauncherTerm | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<LauncherTerm | null>(null);
|
||||||
|
const [menuTerm, setMenuTerm] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [form, setForm] = useState<TermForm>(emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
loadTermsPage({ page, limit: 6, keyword, key: filterValues.key })
|
||||||
|
.then((result) => {
|
||||||
|
setTerms(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
setTerms([]);
|
||||||
|
setTotal(0);
|
||||||
|
setFailed(true);
|
||||||
|
toast({ type: 'error', title: '약관 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [page, keyword, filterValues.key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuTerm) return;
|
||||||
|
|
||||||
|
const close = () => setMenuTerm(null);
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setMenuTerm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('click', close);
|
||||||
|
window.addEventListener('keydown', closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', close);
|
||||||
|
window.removeEventListener('keydown', closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [menuTerm]);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => terms.map((term) => ({ ...term, no: term.no ?? 0, keyLabel: termKeyLabel(term.key) })),
|
||||||
|
[terms]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (term: LauncherTerm) => {
|
||||||
|
setMenuTerm(null);
|
||||||
|
setEditing(term);
|
||||||
|
setForm(toForm(term));
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = () => ({
|
||||||
|
key: form.key.trim(),
|
||||||
|
body: form.body.trim(),
|
||||||
|
isRequired: form.isRequired
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!form.key.trim() || !form.body.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const request = editing ? updateTerm(editing.id, payload()) : createTerm(payload());
|
||||||
|
|
||||||
|
request
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', message: editing ? '약관을 수정했습니다.' : '약관을 추가했습니다.' });
|
||||||
|
closeModal();
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: editing ? '약관 수정 실패' : '약관 추가 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTerm = (term: LauncherTerm) => {
|
||||||
|
setMenuTerm(null);
|
||||||
|
setDeleteTarget(term);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRemoveTerm = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setSaving(true);
|
||||||
|
deleteTerm(deleteTarget.id)
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', message: '약관을 삭제했습니다.' });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '약관 삭제 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
|
{ key: 'key', header: '약관명', width: 160, className: 'px-5 py-3', cell: (row) => <StatusBadge status={row.keyLabel} tone={termKeyTone(row.key)} /> },
|
||||||
|
{
|
||||||
|
key: 'body',
|
||||||
|
header: '본문',
|
||||||
|
flexible: true,
|
||||||
|
className: 'px-5 py-3 font-medium',
|
||||||
|
cell: (row) => <p className="truncate">{row.body}</p>
|
||||||
|
},
|
||||||
|
{ key: 'required', header: '필수 여부', className: 'w-24 px-5 py-3', cell: (row) => <StatusBadge status={row.isRequired ? '필수' : '선택'} tone={row.isRequired ? 'amber' : 'neutral'} /> },
|
||||||
|
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <TermCreator term={row} /> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'w-20 px-5 py-3 font-medium',
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="relative flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="메뉴"
|
||||||
|
aria-label="메뉴"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setMenuTerm((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'key',
|
||||||
|
label: '약관명',
|
||||||
|
options: termKeyOptions,
|
||||||
|
getValue: (row) => row.key
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="운영"
|
||||||
|
title="약관"
|
||||||
|
description="회원가입과 서비스 이용에 필요한 약관을 관리합니다."
|
||||||
|
actions={
|
||||||
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{failed ? (
|
||||||
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
|
약관 데이터를 불러오지 못했습니다.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DataTableCard
|
||||||
|
title="목록"
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
filters={filters}
|
||||||
|
searchPlaceholder="약관 본문, 약관명 검색"
|
||||||
|
getSearchText={(row) => `${row.body} ${row.key} ${row.keyLabel}`}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pageSize={6}
|
||||||
|
minWidth={980}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="등록된 약관이 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{menuTerm ? (() => {
|
||||||
|
const row = rows.find((item) => item.id === menuTerm.id);
|
||||||
|
if (!row) return null;
|
||||||
|
return (
|
||||||
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuTerm.x, top: menuTerm.y, transform: 'translateX(-100%)' }}>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" onClick={() => openEdit(row)}>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10" onClick={() => removeTerm(row)}>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={modalOpen}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Pencil className="size-4 text-primary" />
|
||||||
|
{editing ? '약관 수정' : '약관 추가'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose={closeModal}
|
||||||
|
className="!max-w-2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
|
||||||
|
<Button onClick={submit} disabled={saving || !form.key.trim() || !form.body.trim()}>
|
||||||
|
{saving ? '저장 중' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">약관명 <b className="text-foreground">*</b></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={form.key} onChange={(event) => setForm((prev) => ({ ...prev, key: event.target.value }))}>
|
||||||
|
{termKeyOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">필수 여부</span>
|
||||||
|
<span className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||||
|
<input type="checkbox" checked={form.isRequired} onChange={(event) => setForm((prev) => ({ ...prev, isRequired: event.target.checked }))} />
|
||||||
|
필수
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span>
|
||||||
|
<textarea className="mt-2 min-h-56 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Trash2 className="size-4 text-rose-300" />
|
||||||
|
약관 삭제
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
||||||
|
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmRemoveTerm} disabled={saving}>
|
||||||
|
{saving ? '삭제 중' : '삭제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="pb-6 text-sm text-foreground">약관을 삭제할까요?</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
lockUser,
|
lockUser,
|
||||||
terminateUserSession,
|
terminateUserSession,
|
||||||
unlockUser,
|
unlockUser,
|
||||||
|
updateLmsInstructor,
|
||||||
type LicensePlan,
|
type LicensePlan,
|
||||||
type Organization,
|
type Organization,
|
||||||
type UserSession
|
type UserSession
|
||||||
@@ -45,6 +46,7 @@ export function UserSessionsPage() {
|
|||||||
const [createSaving, setCreateSaving] = useState(false);
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||||
|
const [lmsRoleSaving, setLmsRoleSaving] = useState(false);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
@@ -183,11 +185,29 @@ export function UserSessionsPage() {
|
|||||||
.finally(() => setCreateSaving(false));
|
.finally(() => setCreateSaving(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setLmsInstructor = (isInstructor: boolean) => {
|
||||||
|
if (!detailSession) return;
|
||||||
|
|
||||||
|
setLmsRoleSaving(true);
|
||||||
|
updateLmsInstructor(detailSession.userId, isInstructor)
|
||||||
|
.then(() => {
|
||||||
|
setDetailSession((current) => current ? { ...current, isLmsInstructor: isInstructor } : current);
|
||||||
|
setSessions((current) => current.map((session) => (
|
||||||
|
session.userId === detailSession.userId ? { ...session, isLmsInstructor: isInstructor } : session
|
||||||
|
)));
|
||||||
|
toast({ type: 'success', title: 'LMS 역할 변경', message: isInstructor ? '강사 권한을 부여했습니다.' : '강사 권한을 해제했습니다.' });
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: 'LMS 역할 변경 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setLmsRoleSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="운영"
|
eyebrow="운영"
|
||||||
title="사용자/세션"
|
title="사용자 관리"
|
||||||
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
|
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
|
||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||||
@@ -198,7 +218,7 @@ export function UserSessionsPage() {
|
|||||||
/>
|
/>
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<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 className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
사용자/세션 데이터를 불러오지 못했습니다.
|
사용자 데이터를 불러오지 못했습니다.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
@@ -212,7 +232,7 @@ export function UserSessionsPage() {
|
|||||||
pageSize={6}
|
pageSize={6}
|
||||||
minWidth={900}
|
minWidth={900}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
emptyMessage="표시할 사용자가 없습니다."
|
||||||
serverPagination={{
|
serverPagination={{
|
||||||
page,
|
page,
|
||||||
total,
|
total,
|
||||||
@@ -289,8 +309,20 @@ export function UserSessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<StatusBadge status={detailSession.state} />
|
<StatusBadge status={detailSession.state} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 text-sm">
|
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||||
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
||||||
|
<label className="block min-w-0">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">LMS</span>
|
||||||
|
<select
|
||||||
|
className="mt-2 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"
|
||||||
|
value={detailSession.isLmsInstructor ? 'INSTRUCTOR' : 'STUDENT'}
|
||||||
|
disabled={lmsRoleSaving}
|
||||||
|
onChange={(event) => setLmsInstructor(event.target.value === 'INSTRUCTOR')}
|
||||||
|
>
|
||||||
|
<option value="STUDENT">수강생</option>
|
||||||
|
<option value="INSTRUCTOR">강사</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
||||||
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
||||||
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type UserSession = {
|
|||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
lastSeen?: string | null;
|
lastSeen?: string | null;
|
||||||
state: string;
|
state: string;
|
||||||
|
isLmsInstructor?: boolean;
|
||||||
primaryAffiliation?: {
|
primaryAffiliation?: {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -90,6 +91,11 @@ export type Organization = {
|
|||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
defaultLicenseCode?: string | null;
|
defaultLicenseCode?: string | null;
|
||||||
defaultLicenseName?: string | null;
|
defaultLicenseName?: string | null;
|
||||||
|
instructorInviteCode: string;
|
||||||
|
createdById?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,6 +118,36 @@ export type LauncherNotice = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LauncherTerm = {
|
||||||
|
id: string;
|
||||||
|
no?: number;
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
createdById?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManualDocument = {
|
||||||
|
id: string;
|
||||||
|
no?: number;
|
||||||
|
appKey: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
isPublished: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdById?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
||||||
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
||||||
appPermissions?: Array<string | { appId: string }>;
|
appPermissions?: Array<string | { appId: string }>;
|
||||||
@@ -223,6 +259,13 @@ export function unlockUser(userId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateLmsInstructor(userId: string, isInstructor: boolean) {
|
||||||
|
return apiRequest<{ userId: string; isInstructor: boolean }>(`/users/${userId}/lms-instructor`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ isInstructor })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function terminateUserSession(sessionId: string) {
|
export function terminateUserSession(sessionId: string) {
|
||||||
return apiRequest(`/users/sessions/${sessionId}`, {
|
return apiRequest(`/users/sessions/${sessionId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@@ -332,6 +375,61 @@ export function deleteLauncherNotice(noticeId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadTermsPage(params?: PageParams & { key?: string }): Promise<PagedResponse<LauncherTerm>> {
|
||||||
|
const response = await apiRequest<{ terms?: LauncherTerm[]; total?: number; page?: number; limit?: number }>(`/terms${pageQuery(params)}`);
|
||||||
|
const items = response.terms ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTerm(payload: {
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ term: LauncherTerm }>('/terms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTerm(
|
||||||
|
termId: string,
|
||||||
|
payload: Partial<{
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
return apiRequest<{ term: LauncherTerm }>(`/terms/${termId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTerm(termId: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/terms/${termId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadManualDocumentsPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<ManualDocument>> {
|
||||||
|
const response = await apiRequest<{ documents?: ManualDocument[]; total?: number; page?: number; limit?: number }>(`/manuals${pageQuery(params)}`);
|
||||||
|
const items = response.documents ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManualDocument(payload: Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>) {
|
||||||
|
return apiRequest<{ document: ManualDocument }>('/manuals', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateManualDocument(id: string, payload: Partial<Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>>) {
|
||||||
|
return apiRequest<{ document: ManualDocument }>(`/manuals/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteManualDocument(id: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/manuals/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
export type LicensePlan = {
|
export type LicensePlan = {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@@ -441,15 +441,15 @@ export function RuntimeConfigPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">구성요소</p>
|
<p className="text-sm text-muted-foreground">구성요소</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}개</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">환경변수</p>
|
<p className="text-sm text-muted-foreground">환경변수</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}개</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">시크릿</p>
|
<p className="text-sm text-muted-foreground">시크릿</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}개</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +1,191 @@
|
|||||||
import { Activity, MoreHorizontal, Search, SlidersHorizontal } from 'lucide-react';
|
import { MoreHorizontal, RotateCcw } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn } from '../../components/data-table';
|
||||||
|
import { Badge, Button, Modal } from '../../components/ui';
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { agentDevices } from '../../data/mock';
|
import { loadPlatformDevices, type PlatformDevice } from '../agents/platform-api';
|
||||||
|
|
||||||
function getTone(status: string) {
|
type HeadscaleNode = {
|
||||||
if (['정상', '활성', '성공', '운영', 'Running'].includes(status)) return 'green';
|
id: string;
|
||||||
if (['주의', '예약됨', '대기', '준비중', '카나리', 'Pending'].includes(status)) return 'amber';
|
name: string;
|
||||||
if (['오류', '실패', '비활성', '만료', 'Error', 'Failed'].includes(status)) return 'red';
|
ip: string;
|
||||||
if (['검수', '동기화'].includes(status)) return 'blue';
|
ips?: string[];
|
||||||
return 'neutral';
|
role: string;
|
||||||
|
user: string;
|
||||||
|
routes: string;
|
||||||
|
lastSeen: string | null;
|
||||||
|
status: string;
|
||||||
|
online?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AclRule = { id: string; action: string; sources: string[]; destinations: string[] };
|
||||||
|
type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[]; no: number };
|
||||||
|
|
||||||
|
type DeviceKind = 'agent' | 'launcher' | 'unknown';
|
||||||
|
|
||||||
|
function normalize(value?: string | null) {
|
||||||
|
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDevice(node: HeadscaleNode, devices: PlatformDevice[]) {
|
||||||
|
const nodeValues = [node.name, node.user].map(normalize).filter(Boolean);
|
||||||
|
return devices.find((device) => {
|
||||||
|
const deviceValues = [device.hostname, device.tailscaleHostname, device.accountEmail].map(normalize).filter(Boolean);
|
||||||
|
return nodeValues.some((nodeValue) => deviceValues.some((deviceValue) => nodeValue.includes(deviceValue) || deviceValue.includes(nodeValue)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findControllers(node: HeadscaleNode, rules: AclRule[]) {
|
||||||
|
const destinations = new Set([node.ip, ...(node.ips ?? [])].filter(Boolean).map((ip) => `${ip}:*`));
|
||||||
|
return [...new Set(rules
|
||||||
|
.filter((rule) => rule.action === 'accept' && rule.destinations.some((destination) => destinations.has(destination)))
|
||||||
|
.flatMap((rule) => rule.sources))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceKind(row: PlatformRow): DeviceKind {
|
||||||
|
if (!row.device) return 'unknown';
|
||||||
|
return row.device.agentVersion || row.device.agentLastSeenAt ? 'agent' : 'launcher';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('ko-KR', { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuntimeDeviceControlPage() {
|
export function RuntimeDeviceControlPage() {
|
||||||
|
const [rows, setRows] = useState<PlatformRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState<PlatformRow | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
apiRequest<{ nodes?: HeadscaleNode[] }>('/tailscale/nodes'),
|
||||||
|
apiRequest<{ policy?: { acls?: AclRule[] } }>('/tailscale/acl'),
|
||||||
|
loadPlatformDevices(),
|
||||||
|
])
|
||||||
|
.then(([nodeResponse, aclResponse, devices]) => {
|
||||||
|
const rules = aclResponse.policy?.acls ?? [];
|
||||||
|
const nodes = nodeResponse.nodes ?? [];
|
||||||
|
setRows(nodes.map((node, index) => ({
|
||||||
|
...node,
|
||||||
|
no: nodes.length - index,
|
||||||
|
device: findDevice(node, devices),
|
||||||
|
controllers: findControllers(node, rules),
|
||||||
|
})));
|
||||||
|
})
|
||||||
|
.catch(() => setRows([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<PlatformRow>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||||
|
{
|
||||||
|
key: 'platform',
|
||||||
|
header: '플랫폼',
|
||||||
|
flexible: true,
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold" title={row.name}>{row.name}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.user}>{row.user || '-'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'connection',
|
||||||
|
header: '연결 대상',
|
||||||
|
width: 180,
|
||||||
|
cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.ip || '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deviceKind',
|
||||||
|
header: '유형',
|
||||||
|
width: 150,
|
||||||
|
cell: (row) => {
|
||||||
|
const kind = getDeviceKind(row);
|
||||||
|
if (kind === 'agent') {
|
||||||
|
return <Badge tone="green">에이전트</Badge>;
|
||||||
|
}
|
||||||
|
if (kind === 'launcher') return <Badge tone="neutral">런처 클라이언트</Badge>;
|
||||||
|
return <Badge tone="neutral">미확인</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'controllers',
|
||||||
|
header: '제어 사용자',
|
||||||
|
width: 280,
|
||||||
|
cell: (row) => (
|
||||||
|
<span className="block truncate text-muted-foreground" title={row.controllers.length ? row.controllers.join(', ') : row.user || '-'}>
|
||||||
|
{row.controllers.length ? row.controllers.join(', ') : row.user || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'routes', header: '연결 경로', width: 190, cell: (row) => <span className="block truncate text-muted-foreground" title={row.routes || '-'}>{row.routes || '-'}</span> },
|
||||||
|
{ key: 'status', header: '상태', width: 110, cell: (row) => <Badge tone={row.online || row.status === '활성' ? 'green' : 'neutral'}>{row.online || row.status === '활성' ? '연결됨' : '오프라인'}</Badge> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" className="size-8 px-0" title="상세" aria-label="상세" onClick={() => setSelected(row)}>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="Agent" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
<PageTitle
|
||||||
<Card className="min-w-0 overflow-hidden">
|
eyebrow="플랫폼 관리"
|
||||||
<CardHeader
|
title="연결 제어"
|
||||||
title="장비 목록"
|
description="Headscale 노드 연결 상태와 ACL 기반 제어 권한을 확인합니다."
|
||||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
actions={
|
||||||
action={
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||||
<div className="flex gap-2">
|
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
새로고침
|
||||||
<SlidersHorizontal className="size-4" />
|
</Button>
|
||||||
상태 필터
|
}
|
||||||
</Button>
|
/>
|
||||||
<Button className="h-8 px-3 text-[13px]">
|
<DataTableCard
|
||||||
<Activity className="size-4" />
|
title="연결 플랫폼"
|
||||||
상태 갱신
|
description="Headscale에 등록된 노드와 해당 노드에 적용된 ACL 제어 사용자를 표시합니다."
|
||||||
</Button>
|
rows={rows}
|
||||||
</div>
|
columns={columns}
|
||||||
}
|
searchPlaceholder="플랫폼, 사용자, IP, ACL 검색"
|
||||||
/>
|
getSearchText={(row) => `${row.name} ${row.user} ${row.ip} ${row.controllers.join(' ')} ${row.routes}`}
|
||||||
<div className="border-b border-border px-5 pb-4">
|
getRowKey={(row) => row.id || row.name}
|
||||||
<div className="relative max-w-md">
|
pageSize={6}
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
minWidth={1100}
|
||||||
<input
|
loading={loading}
|
||||||
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"
|
emptyMessage="Headscale에 연결된 플랫폼이 없습니다."
|
||||||
placeholder="장비명, 계정, 워크스페이스 검색"
|
/>
|
||||||
/>
|
<Modal open={Boolean(selected)} title="플랫폼 연결 상세" onClose={() => setSelected(null)}>
|
||||||
</div>
|
<dl className="grid grid-cols-[140px_minmax(0,1fr)] gap-x-4 gap-y-4 text-sm">
|
||||||
</div>
|
<dt className="text-muted-foreground">플랫폼</dt><dd className="min-w-0 break-all font-medium">{selected?.name}</dd>
|
||||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
<dt className="text-muted-foreground">Headscale 사용자</dt><dd className="min-w-0 break-all">{selected?.user || '-'}</dd>
|
||||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
<dt className="text-muted-foreground">연결 IP</dt><dd className="font-mono text-xs">{selected?.ips?.join(', ') || selected?.ip || '-'}</dd>
|
||||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
<dt className="text-muted-foreground">ACL 제어 사용자</dt><dd className="min-w-0 break-all">{selected?.controllers.length ? selected.controllers.join(', ') : '개별 ACL 규칙 없음'}</dd>
|
||||||
<tr>
|
<dt className="text-muted-foreground">연결 경로</dt><dd className="min-w-0 break-all">{selected?.routes || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">장비</th>
|
<dt className="text-muted-foreground">Workspace</dt><dd className="min-w-0 break-all">{selected?.device?.workspaceRoot || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">계정</th>
|
<dt className="text-muted-foreground">SSH 사용자</dt><dd>{selected?.device?.sshUser || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
<dt className="text-muted-foreground">사용 유형</dt><dd>{selected?.device?.usageType || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
<dt className="text-muted-foreground">장비 유형</dt><dd>{selected ? ({ agent: '에이전트', launcher: '런처 클라이언트', unknown: '미확인' }[getDeviceKind(selected)]) : '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
<dt className="text-muted-foreground">에이전트 버전</dt><dd>{selected?.device?.agentVersion || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">상태</th>
|
<dt className="text-muted-foreground">마지막 에이전트 확인</dt><dd>{formatDateTime(selected?.device?.agentLastSeenAt)}</dd>
|
||||||
<th className="px-5 py-3 text-right font-medium">명령</th>
|
</dl>
|
||||||
</tr>
|
</Modal>
|
||||||
</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>
|
|
||||||
<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="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
|
||||||
<Button variant="ghost" className="size-8 px-0">
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export function loadRuntimeResources(namespace: string) {
|
|||||||
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadRuntimeResourceYaml(namespace: string, kind: string, name: string) {
|
||||||
|
return apiRequest<{ namespace: string; kind: string; name: string; yaml: string }>(
|
||||||
|
`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/yaml`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function loadRuntimePodMetrics(namespace?: string) {
|
export function loadRuntimePodMetrics(namespace?: string) {
|
||||||
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
||||||
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods${query}`);
|
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods${query}`);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type BuildJob = {
|
|||||||
branch: string;
|
branch: string;
|
||||||
commitSha?: string | null;
|
commitSha?: string | null;
|
||||||
artifact?: string | null;
|
artifact?: string | null;
|
||||||
|
artifactSha256?: string | null;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user