Compare commits

..

65 Commits

Author SHA1 Message Date
chyoo cad561dfd0 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 13:56:27 +09:00
chyoo dd2fc93a59 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 13:50:25 +09:00
chyoo e1a5fd54db merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 13:23:44 +09:00
chyoo 6c14203e8f merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 13:21:39 +09:00
chyoo be3f36f6d9 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 13:09:39 +09:00
chyoo f510b1957e merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 12:54:07 +09:00
chyoo 5bb42621d0 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 11:55:56 +09:00
chyoo e3ae6e3f43 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 11:27:42 +09:00
chyoo 556f5866a7 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 11:20:20 +09:00
chyoo c7a6693ce4 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 11:05:04 +09:00
chyoo 15d993555f merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 11:01:36 +09:00
chyoo 39b55ad479 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 10:53:51 +09:00
chyoo 1b49256af6 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 10:22:15 +09:00
chyoo 81fc8f842a merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 10:16:30 +09:00
chyoo 44c6c08a02 replace launcher common settings with internal launcher settings
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 09:46:39 +09:00
chyoo 38a13f29fd replace launcher common settings with internal launcher settings
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-09 09:07:00 +09:00
chyoo 840fa0a604 replace launcher common settings with internal launcher settings
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 18:13:59 +09:00
chyoo c594c1cb85 replace launcher common settings with internal launcher settings
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 18:08:09 +09:00
chyoo e4b968e5dd replace launcher common settings with internal launcher settings
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 18:02:13 +09:00
chyoo 48b81dd5cf replace launcher common settings with internal launcher settings
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 17:51:20 +09:00
chyoo 3b471245c7 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 17:13:46 +09:00
chyoo 5ebab95a8b merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 16:39:15 +09:00
chyoo 8acbb760c8 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 15:35:57 +09:00
chyoo 8d37cf86a2 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 15:24:12 +09:00
chyoo 310daaa424 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 14:40:56 +09:00
chyoo d08a1a1a79 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-08 11:05:02 +09:00
chyoo c4c2a845ea merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 17:42:21 +09:00
chyoo e1d9d803d5 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 17:08:33 +09:00
chyoo 79e2e8443d merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 15:56:22 +09:00
chyoo 86e4330066 merge develop into release
ci/woodpecker/push/woodpecker Pipeline failed
2026-07-07 15:51:55 +09:00
chyoo 5ec75d68ab merge develop into release
ci/woodpecker/push/woodpecker Pipeline failed
2026-07-07 15:47:59 +09:00
chyoo 5bae18678c merge develop into release
ci/woodpecker/push/woodpecker Pipeline failed
2026-07-07 15:32:59 +09:00
chyoo 641166ba27 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 11:56:33 +09:00
chyoo e6bbaf4971 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 10:20:31 +09:00
chyoo 039a2ca285 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 10:18:14 +09:00
chyoo c345dade10 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 10:15:43 +09:00
chyoo 50e9b1d5ee merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 09:56:33 +09:00
chyoo e08ae4639b merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 09:51:28 +09:00
chyoo 3bb54af459 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 09:43:22 +09:00
chyoo 99f362fda1 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 09:29:49 +09:00
chyoo 622fe6e4b3 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 09:24:37 +09:00
chyoo bbda7887ee merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 09:20:10 +09:00
chyoo e2bb9075bd merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-07 09:05:21 +09:00
chyoo f6284aef79 merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 18:30:40 +09:00
chyoo a9c7a3654b Merge remote-tracking branch 'origin/develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 17:50:00 +09:00
chyoo 8c7c26e9c0 Merge remote-tracking branch 'origin/develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 17:15:57 +09:00
chyoo 070426620e Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 16:32:07 +09:00
chyoo 7e56b93f01 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 14:22:24 +09:00
chyoo 369de72e98 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 10:50:48 +09:00
chyoo 052b87e4ff Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 10:47:48 +09:00
chyoo 20d5ccef00 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 10:25:15 +09:00
chyoo b2cfd84f10 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-06 10:03:57 +09:00
chyoo 6da6e2a980 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 17:26:46 +09:00
chyoo 1118ff5663 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 17:23:46 +09:00
chyoo d529aa6f19 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 17:18:55 +09:00
chyoo e55d2f536a Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 17:00:38 +09:00
chyoo f6d9c8d0bb Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 16:30:16 +09:00
chyoo b6d1ba743b Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 16:08:04 +09:00
chyoo d5516c6cf8 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 15:33:32 +09:00
chyoo 92c0cf414b Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 15:18:14 +09:00
chyoo 718ce8d09f retry woodpecker release deploy
ci/woodpecker/push/woodpecker Pipeline failed
2026-07-03 14:58:48 +09:00
chyoo a9a9024830 Merge branch 'develop' into release 2026-07-03 14:56:01 +09:00
chyoo f7dfce5918 Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-03 14:52:00 +09:00
chyoo c6d3ff8f5c test woodpecker release deploy 2026-07-03 14:47:43 +09:00
chyoo ded18b44e5 test woodpecker release deploy 2026-07-03 14:46:01 +09:00
46 changed files with 5904 additions and 4565 deletions
+55 -632
View File
@@ -1,22 +1,18 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { import {
Activity, Activity,
Camera,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CircleHelp, CircleHelp,
Loader2,
LogOut, LogOut,
Settings,
Search, Search,
SlidersHorizontal, SlidersHorizontal
Trash2
} from 'lucide-react'; } from 'lucide-react';
import { navGroups } from './layout/navigation'; import { navGroups } from './data/mock';
import { Badge, Button, Card, CardHeader, Modal, Progress } from './components/ui'; import { Badge, Button, Card, CardHeader, 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, apiRequest, authExpiredEventName, clearAdminSession } from './lib/api';
import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions'; import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
import { cn } from './lib/utils'; import { cn } from './lib/utils';
import { openPageAgent } from './lib/page-agent'; import { openPageAgent } from './lib/page-agent';
@@ -28,12 +24,9 @@ import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPag
import { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage'; import { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage';
import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage'; import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage';
import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage'; import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage';
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';
@@ -58,8 +51,13 @@ 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
@@ -78,8 +76,6 @@ type ViewKey =
| 'launcherLogs' | 'launcherLogs'
| 'launcherApps' | 'launcherApps'
| 'notices' | 'notices'
| 'terms'
| 'manuals'
| 'appManagement' | 'appManagement'
| 'appRepositories' | 'appRepositories'
| 'appSources' | 'appSources'
@@ -87,6 +83,9 @@ type ViewKey =
| 'appReleases' | 'appReleases'
| 'appDeployments' | 'appDeployments'
| 'appMigrations' | 'appMigrations'
| 'gatewayStatus'
| 'gatewayAgents'
| 'gatewayDevices'
| 'tailscaleStatus' | 'tailscaleStatus'
| 'tailscaleRoutes' | 'tailscaleRoutes'
| 'tailscaleAcl' | 'tailscaleAcl'
@@ -98,6 +97,7 @@ type ViewKey =
| 'agentRegistration' | 'agentRegistration'
| 'agentInstallStatus' | 'agentInstallStatus'
| 'agentSshKeys' | 'agentSshKeys'
| 'agentWorkspace'
| 'agentCommands' | 'agentCommands'
| 'runtimeConfig' | 'runtimeConfig'
| 'runtimeLogs' | 'runtimeLogs'
@@ -113,8 +113,6 @@ const viewByLabel: Record<string, ViewKey> = {
구성요소: 'launcherComponents', 구성요소: 'launcherComponents',
'앱 연결': 'launcherApps', '앱 연결': 'launcherApps',
공지사항: 'notices', 공지사항: 'notices',
약관: 'terms',
매뉴얼: 'manuals',
'앱 관리': 'appManagement', '앱 관리': 'appManagement',
'저장소 관리': 'appRepositories', '저장소 관리': 'appRepositories',
'소스 관리': 'appSources', '소스 관리': 'appSources',
@@ -123,6 +121,9 @@ const viewByLabel: Record<string, ViewKey> = {
'실행 설정': 'runtimeConfig', '실행 설정': 'runtimeConfig',
'배포 관리': 'appDeployments', '배포 관리': 'appDeployments',
마이그레이션: 'appMigrations', 마이그레이션: 'appMigrations',
'연결 상태': 'gatewayStatus',
'Agent 관리': 'gatewayAgents',
'장비 동기화': 'gatewayDevices',
'노드 현황': 'tailscaleStatus', '노드 현황': 'tailscaleStatus',
라우트: 'tailscaleRoutes', 라우트: 'tailscaleRoutes',
ACL: 'tailscaleAcl', ACL: 'tailscaleAcl',
@@ -131,14 +132,16 @@ const viewByLabel: Record<string, ViewKey> = {
네트워크: 'infraNetwork', 네트워크: 'infraNetwork',
스토리지: 'infraStorage', 스토리지: 'infraStorage',
모니터링: 'monitoring', 모니터링: 'monitoring',
'플랫폼 등록': 'agentRegistration', '장비 등록': 'agentRegistration',
'SSH 키': 'agentSshKeys', '설치 상태': 'agentInstallStatus',
'SSH 키 배포': 'agentSshKeys',
'Workspace 설정': 'agentWorkspace',
'원격 명령': 'agentCommands', '원격 명령': 'agentCommands',
'로그/이벤트': 'runtimeLogs', '로그/이벤트': 'runtimeLogs',
'연결 제어': 'runtimeDeviceControl', '장비 제어': 'runtimeDeviceControl',
'관리자 관리': 'adminManagement', '관리자 관리': 'adminManagement',
'조직 관리': 'organizations', '조직 관리': 'organizations',
'사용자 관리': 'userSessions', '사용자/세션': 'userSessions',
라이센스: 'licenses' 라이센스: 'licenses'
}; };
@@ -155,8 +158,6 @@ 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',
@@ -164,6 +165,9 @@ 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',
@@ -175,6 +179,7 @@ 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',
@@ -195,19 +200,9 @@ function viewFromPath(pathname: string): ViewKey {
} }
type CurrentAdmin = { type CurrentAdmin = {
id?: string;
email: string; email: string;
name?: string; name?: string;
role?: string; role?: string;
avatarUrl?: string | null;
appPermissions?: string[];
sshKeys?: Array<{
id: string;
label?: string | null;
fingerprint?: string | null;
giteaUsername?: string | null;
createdAt?: string | null;
}>;
}; };
function readCurrentAdmin(): CurrentAdmin | null { function readCurrentAdmin(): CurrentAdmin | null {
@@ -221,59 +216,6 @@ function readCurrentAdmin(): CurrentAdmin | null {
} }
} }
async function fetchCurrentAdmin() {
const response = await apiFetch(`${apiBaseUrl}/auth/admin/me`, { headers: authHeaders() });
if (!response.ok) throw new Error('Admin profile request failed');
const data = await response.json() as { admin: CurrentAdmin };
return data.admin;
}
function storeCurrentAdmin(admin: CurrentAdmin) {
localStorage.setItem('studio-admin', JSON.stringify(admin));
}
async function resizeImageToWebp(file: File, size = 128) {
if (!file.type.startsWith('image/')) {
throw new Error('이미지 파일만 업로드할 수 있습니다.');
}
const sourceUrl = URL.createObjectURL(file);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = sourceUrl;
});
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('이미지 처리에 실패했습니다.');
}
const sourceSize = Math.min(image.width, image.height);
const sourceX = (image.width - sourceSize) / 2;
const sourceY = (image.height - sourceSize) / 2;
ctx.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size);
const qualitySteps = [0.82, 0.72, 0.62, 0.52];
for (const quality of qualitySteps) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
if (blob && blob.size <= 80 * 1024) return blob;
}
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', 0.45));
if (!blob) throw new Error('이미지 변환에 실패했습니다.');
return blob;
} finally {
URL.revokeObjectURL(sourceUrl);
}
}
type ApiAppComponent = { type ApiAppComponent = {
id: string; id: string;
appId: string; appId: string;
@@ -576,11 +518,10 @@ 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; path?: string }; type NavChild = { label: string; view?: string };
function resolveNavView(item: NavItem | NavChild): ViewKey | undefined { function resolveNavView(item: NavItem | NavChild): ViewKey | undefined {
const path = 'path' in item ? item.path : undefined; return 'view' in item && item.view ? item.view as ViewKey : viewByLabel[item.label];
return path ? viewByPath[path] : viewByLabel[item.label];
} }
function isOwnerRole(role?: string | null) { function isOwnerRole(role?: string | null) {
@@ -685,10 +626,9 @@ 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 children = item.children; const hasChildren = 'children' in item && item.children;
const hasChildren = Boolean(children?.length);
const isParentActive = hasChildren const isParentActive = hasChildren
? children!.some((child) => resolveNavView(child) === activeView) ? item.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);
@@ -723,7 +663,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">
{children!.map((child) => { {item.children.map((child) => {
const isChildActive = activeView === resolveNavView(child); const isChildActive = activeView === resolveNavView(child);
return ( return (
@@ -791,230 +731,17 @@ function Topbar({
menuSearch, menuSearch,
onMenuSearchChange, onMenuSearchChange,
currentAdmin, currentAdmin,
onAdminChange,
onLogout onLogout
}: { }: {
menuSearch: string; menuSearch: string;
onMenuSearchChange: (value: string) => void; onMenuSearchChange: (value: string) => void;
currentAdmin: CurrentAdmin | null; currentAdmin: CurrentAdmin | null;
onAdminChange: (admin: CurrentAdmin) => void;
onLogout: () => void; onLogout: () => void;
}) { }) {
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [avatarDeleteConfirmOpen, setAvatarDeleteConfirmOpen] = useState(false);
const [profileSshModalOpen, setProfileSshModalOpen] = useState(false);
const [profileSshDeleteConfirmOpen, setProfileSshDeleteConfirmOpen] = useState(false);
const [avatarFailed, setAvatarFailed] = useState(false);
const [avatarRevision, setAvatarRevision] = useState(0);
const [avatarUploading, setAvatarUploading] = useState(false);
const [avatarDeleting, setAvatarDeleting] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordMessage, setPasswordMessage] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [profileSshLabel, setProfileSshLabel] = useState('');
const [profileSshPublicKey, setProfileSshPublicKey] = useState('');
const [profileSshSaving, setProfileSshSaving] = useState(false);
const [profileSshDeleting, setProfileSshDeleting] = useState(false);
const [profileSshError, setProfileSshError] = useState<string | null>(null);
const profileRef = useRef<HTMLDivElement | null>(null); const profileRef = useRef<HTMLDivElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const accountLabel = currentAdmin?.email ?? '관리자'; const accountLabel = currentAdmin?.email ?? '관리자';
const avatarLetter = accountLabel.trim().charAt(0).toUpperCase() || 'A'; const avatarLetter = accountLabel.trim().charAt(0).toUpperCase() || 'A';
const avatarUrl = currentAdmin?.avatarUrl && !avatarFailed ? currentAdmin.avatarUrl : '';
const avatarSrc = avatarUrl ? `${avatarUrl}${avatarUrl.includes('?') ? '&' : '?'}v=${avatarRevision}` : '';
const roleLabel = currentAdmin?.role ?? '-';
const appPermissionCount = currentAdmin?.appPermissions?.length ?? 0;
const sshKeys = currentAdmin?.sshKeys ?? [];
const openProfileSshModal = () => {
const account = currentAdmin?.email.split('@')[0] || 'admin';
const random = Math.random().toString(36).slice(2, 8);
setProfileSshLabel(`${account}-${random}-key`);
setProfileSshPublicKey('');
setProfileSshError(null);
setProfileSshModalOpen(true);
};
const closeProfileSshModal = () => {
setProfileSshModalOpen(false);
setProfileSshLabel('');
setProfileSshPublicKey('');
setProfileSshError(null);
};
const closePasswordModal = () => {
setPasswordModalOpen(false);
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirm('');
setPasswordError(null);
setPasswordMessage(null);
};
useEffect(() => {
setAvatarFailed(false);
setAvatarError(null);
}, [currentAdmin?.avatarUrl]);
const refreshAdmin = async () => {
const admin = await fetchCurrentAdmin();
storeCurrentAdmin(admin);
onAdminChange(admin);
setAvatarFailed(false);
setAvatarRevision((revision) => revision + 1);
};
const uploadAvatar = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file || avatarUploading || avatarDeleting) return;
setAvatarUploading(true);
setAvatarError(null);
try {
const imageBlob = await resizeImageToWebp(file, 128);
const response = await apiFetch(`${apiBaseUrl}/auth/admin/avatar`, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': imageBlob.type }),
body: imageBlob,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '프로필 이미지 업로드에 실패했습니다.');
}
await refreshAdmin();
} catch (error) {
setAvatarError(error instanceof Error ? error.message : '프로필 이미지 업로드에 실패했습니다.');
} finally {
setAvatarUploading(false);
}
};
const deleteAvatar = async () => {
if (!avatarUrl || avatarUploading || avatarDeleting) return;
setAvatarDeleting(true);
setAvatarError(null);
try {
const response = await apiFetch(`${apiBaseUrl}/auth/admin/avatar`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '프로필 이미지 삭제에 실패했습니다.');
}
await refreshAdmin();
setAvatarDeleteConfirmOpen(false);
} catch (error) {
setAvatarError(error instanceof Error ? error.message : '프로필 이미지 삭제에 실패했습니다.');
} finally {
setAvatarDeleting(false);
}
};
const submitPasswordChange = async () => {
setPasswordMessage(null);
setPasswordError(null);
if (!currentPassword || !newPassword || !newPasswordConfirm) {
setPasswordError('비밀번호를 모두 입력해 주세요.');
return;
}
if (newPassword.length < 8) {
setPasswordError('새 비밀번호는 8자 이상이어야 합니다.');
return;
}
if (newPassword !== newPasswordConfirm) {
setPasswordError('새 비밀번호 확인이 일치하지 않습니다.');
return;
}
setPasswordSaving(true);
try {
const response = await apiFetch(`${apiBaseUrl}/auth/admin/password`, {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '비밀번호 변경에 실패했습니다.');
}
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirm('');
setPasswordMessage('비밀번호가 변경되었습니다.');
} catch (error) {
setPasswordError(error instanceof Error ? error.message : '비밀번호 변경에 실패했습니다.');
} finally {
setPasswordSaving(false);
}
};
const submitProfileSshKey = async () => {
if (!currentAdmin?.id || !profileSshPublicKey.trim() || profileSshSaving) return;
setProfileSshSaving(true);
setProfileSshError(null);
try {
await createAdminSshKey(currentAdmin.id, {
label: profileSshLabel.trim() || undefined,
publicKey: profileSshPublicKey.trim(),
});
await refreshAdmin();
closeProfileSshModal();
} catch (error) {
setProfileSshError(error instanceof Error ? error.message : 'SSH 키 등록에 실패했습니다.');
} finally {
setProfileSshSaving(false);
}
};
const deleteProfileSshKey = async () => {
const keyId = sshKeys[0]?.id;
if (!currentAdmin?.id || !keyId || profileSshDeleting) return;
setProfileSshDeleting(true);
setProfileSshError(null);
try {
const response = await apiFetch(`${apiBaseUrl}/admins/${currentAdmin.id}/ssh-keys/${keyId}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || 'SSH 키 삭제에 실패했습니다.');
}
await refreshAdmin();
setProfileSshDeleteConfirmOpen(false);
} catch (error) {
setProfileSshError(error instanceof Error ? error.message : 'SSH 키 삭제에 실패했습니다.');
} finally {
setProfileSshDeleting(false);
}
};
useEffect(() => { useEffect(() => {
const closeProfile = (event: MouseEvent) => { const closeProfile = (event: MouseEvent) => {
@@ -1025,7 +752,6 @@ function Topbar({
}, []); }, []);
return ( return (
<>
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-border bg-background/90 px-4 backdrop-blur-xl md:px-6"> <header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-border bg-background/90 px-4 backdrop-blur-xl md:px-6">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Button variant="outline" className="lg:hidden"> <Button variant="outline" className="lg:hidden">
@@ -1047,36 +773,18 @@ function Topbar({
</Button> </Button>
<div ref={profileRef} className="relative"> <div ref={profileRef} className="relative">
<Button variant="outline" className="gap-2" onClick={() => setProfileOpen((open) => !open)}> <Button variant="outline" className="gap-2" onClick={() => setProfileOpen((open) => !open)}>
<span className="flex size-6 items-center justify-center rounded bg-muted text-xs font-semibold">
{avatarLetter}
</span>
<span className="hidden max-w-48 truncate md:inline">{accountLabel}</span> <span className="hidden max-w-48 truncate md:inline">{accountLabel}</span>
<ChevronDown className="size-4 text-muted-foreground" /> <ChevronDown className="size-4 text-muted-foreground" />
</Button> </Button>
{profileOpen ? ( {profileOpen ? (
<div className="absolute right-0 top-11 z-50 w-64 overflow-hidden rounded-md border border-border bg-card shadow-xl"> <div className="absolute right-0 top-11 z-50 w-64 overflow-hidden rounded-md border border-border bg-card shadow-xl">
<div className="flex items-center gap-3 border-b border-border px-4 py-3"> <div className="border-b border-border px-4 py-3">
<span className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-sm font-semibold"> <p className="truncate text-sm text-foreground">{currentAdmin?.name || accountLabel}</p>
{avatarUrl ? ( <p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
<img className="h-full w-full object-cover" src={avatarSrc} alt="" onError={() => setAvatarFailed(true)} />
) : avatarLetter}
</span>
<div className="min-w-0">
<p className="truncate text-sm text-foreground">{currentAdmin?.name || accountLabel}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
</div>
</div> </div>
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
onClick={() => {
setProfileOpen(false);
setProfileSettingsOpen(true);
setAvatarError(null);
setPasswordError(null);
setPasswordMessage(null);
}}
>
<Settings className="size-4" />
프로필 설정
</button>
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
@@ -1093,258 +801,6 @@ function Topbar({
</div> </div>
</div> </div>
</header> </header>
<Modal
open={profileSettingsOpen}
title="프로필 설정"
onClose={() => setProfileSettingsOpen(false)}
className="max-w-xl"
footer={<Button variant="outline" onClick={() => setProfileSettingsOpen(false)}>닫기</Button>}
>
<div className="space-y-6">
<section className="flex items-center gap-4 rounded-md border border-border bg-muted/10 p-4">
<div className="relative size-20 shrink-0">
<span className="flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-muted text-xl font-semibold">
{avatarUrl ? (
<img className="h-full w-full object-cover" src={avatarSrc} alt="" onError={() => setAvatarFailed(true)} />
) : avatarLetter}
</span>
{avatarUrl ? (
<button
type="button"
className="absolute right-0 top-0 z-10 flex size-7 items-center justify-center rounded-full border border-rose-400/70 bg-card text-rose-300 shadow-sm transition-colors hover:bg-rose-500/15 hover:text-rose-200 disabled:cursor-not-allowed disabled:opacity-60"
title="프로필 이미지 삭제"
aria-label="프로필 이미지 삭제"
disabled={avatarUploading || avatarDeleting}
onClick={() => setAvatarDeleteConfirmOpen(true)}
>
{avatarDeleting ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
</button>
) : null}
<button
type="button"
className="absolute bottom-0 right-0 flex size-8 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:border-primary/60 hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
title="프로필 이미지 업로드"
aria-label="프로필 이미지 업로드"
disabled={avatarUploading || avatarDeleting}
onClick={() => avatarInputRef.current?.click()}
>
{avatarUploading ? <Loader2 className="size-4 animate-spin" /> : <Camera className="size-4" />}
</button>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={uploadAvatar}
/>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{currentAdmin?.name || accountLabel}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
{avatarError ? <p className="mt-2 line-clamp-2 text-xs text-rose-300">{avatarError}</p> : null}
</div>
</section>
<section>
<h3 className="text-sm font-semibold text-foreground">권한 정보</h3>
<div className="mt-3 grid gap-3">
<ProfileInfoRow label="역할" value={roleLabel} />
<ProfileInfoRow label="앱 권한" value={isOwnerRole(currentAdmin?.role) ? '전체 앱' : `${appPermissionCount}개 앱`} />
</div>
</section>
<section className="rounded-md border border-border bg-muted/10 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">SSH 키</h3>
<p className="mt-1 text-xs text-muted-foreground">관리자 계정에 등록된 저장소 접근 키입니다.</p>
</div>
<div className="flex items-center gap-2">
{sshKeys.length > 0 ? (
<Button
variant="outline"
className="h-8 border-rose-500/40 px-3 text-xs text-rose-200 hover:bg-rose-500/10"
disabled={!currentAdmin?.id}
onClick={() => {
setProfileSshError(null);
setProfileSshDeleteConfirmOpen(true);
}}
>
삭제
</Button>
) : null}
{!sshKeys.length ? (
<Button variant="outline" className="h-8 px-3 text-xs" disabled={!currentAdmin?.id} onClick={openProfileSshModal}>
등록
</Button>
) : null}
</div>
</div>
<div className="mt-4 rounded-md border border-border bg-background/40">
{sshKeys.length ? sshKeys.map((key) => (
<div key={key.id} className="border-b border-border px-3 py-2 last:border-b-0">
<p className="truncate text-sm font-semibold text-foreground">{key.label ?? 'SSH Key'}</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{key.fingerprint ?? '-'}</p>
{key.giteaUsername ? <p className="mt-1 text-xs text-muted-foreground">저장소 사용자명 {key.giteaUsername}</p> : null}
</div>
)) : (
<p className="px-3 py-4 text-sm text-muted-foreground">등록된 SSH 키가 없습니다.</p>
)}
</div>
</section>
<section className="flex items-center justify-between gap-4 rounded-md border border-border bg-muted/10 p-4">
<div>
<h3 className="text-sm font-semibold text-foreground">비밀번호</h3>
<p className="mt-1 text-xs text-muted-foreground">현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</p>
</div>
<Button
variant="outline"
onClick={() => {
setPasswordModalOpen(true);
setPasswordError(null);
setPasswordMessage(null);
}}
>
비밀번호 변경
</Button>
</section>
</div>
</Modal>
<Modal
open={avatarDeleteConfirmOpen}
title="프로필 이미지 삭제"
onClose={() => setAvatarDeleteConfirmOpen(false)}
footer={
<>
<Button variant="outline" onClick={() => setAvatarDeleteConfirmOpen(false)}>취소</Button>
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" disabled={avatarDeleting} onClick={deleteAvatar}>
{avatarDeleting ? '삭제 중' : '삭제'}
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">현재 프로필 이미지를 삭제합니다. 삭제 후 기본 이니셜 이미지로 표시됩니다.</p>
{avatarError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{avatarError}</p> : null}
</div>
</Modal>
<Modal
open={profileSshModalOpen}
title="SSH 키 등록"
onClose={closeProfileSshModal}
className="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={closeProfileSshModal}>취소</Button>
<Button disabled={profileSshSaving || !profileSshPublicKey.trim()} onClick={submitProfileSshKey}>
{profileSshSaving ? '등록 중' : '등록'}
</Button>
</>
}
>
<div className="grid gap-4">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={profileSshLabel}
onChange={(event) => setProfileSshLabel(event.target.value)}
/>
</label>
<p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
저장소 사용자명은 관리자 이메일 기준으로 자동 적용됩니다.
</p>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">공개키</span>
<textarea
className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={profileSshPublicKey}
onChange={(event) => setProfileSshPublicKey(event.target.value)}
/>
</label>
{profileSshError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{profileSshError}</p> : null}
</div>
</Modal>
<Modal
open={profileSshDeleteConfirmOpen}
title="SSH 키 삭제"
onClose={() => setProfileSshDeleteConfirmOpen(false)}
footer={
<>
<Button variant="outline" onClick={() => setProfileSshDeleteConfirmOpen(false)}>취소</Button>
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" disabled={profileSshDeleting} onClick={deleteProfileSshKey}>
{profileSshDeleting ? '삭제 중' : '삭제'}
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">등록된 SSH 키를 삭제합니다. Gitea에 등록된 키도 함께 삭제됩니다.</p>
{profileSshError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{profileSshError}</p> : null}
</div>
</Modal>
<Modal
open={passwordModalOpen}
title="비밀번호 변경"
onClose={closePasswordModal}
footer={
<>
<Button variant="outline" onClick={closePasswordModal}>취소</Button>
<Button
disabled={passwordSaving || !currentPassword || !newPassword || !newPasswordConfirm}
onClick={submitPasswordChange}
>
{passwordSaving ? '변경 중' : '변경'}
</Button>
</>
}
>
<div className="grid gap-4">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">현재 비밀번호</span>
<input
type="password"
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">새 비밀번호</span>
<input
type="password"
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">새 비밀번호 확인</span>
<input
type="password"
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
value={newPasswordConfirm}
onChange={(event) => setNewPasswordConfirm(event.target.value)}
/>
</label>
{passwordError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{passwordError}</p> : null}
{passwordMessage ? <p className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">{passwordMessage}</p> : null}
</div>
</Modal>
</>
);
}
function ProfileInfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border bg-muted/10 px-3 py-2">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-medium text-foreground">{value}</p>
</div>
); );
} }
@@ -1369,7 +825,7 @@ function LoginPage({ onLogin }: { onLogin: (admin: CurrentAdmin) => void }) {
}) })
.then((response) => { .then((response) => {
if (!response.ok) throw new Error('Admin login failed'); if (!response.ok) throw new Error('Admin login failed');
return response.json() as Promise<{ token: string; expiresAt: string; admin: CurrentAdmin }>; return response.json() as Promise<{ token: string; expiresAt: string; admin: { email: string; name: string; role: string } }>;
}) })
.then((data) => { .then((data) => {
localStorage.setItem('studio-admin-token', data.token); localStorage.setItem('studio-admin-token', data.token);
@@ -1507,8 +963,8 @@ function SkeletonBlock({ className }: { className?: string }) {
return ( return (
<div <div
className={cn( className={cn(
'relative overflow-hidden rounded-md bg-white/15', 'relative overflow-hidden rounded-md bg-white/10',
'before:absolute before:inset-0 before:-translate-x-full before:animate-[skeleton-shimmer_1.35s_ease-in-out_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/35 before:to-transparent', 'before:absolute before:inset-0 before:-translate-x-full before:animate-[skeleton-shimmer_1.35s_ease-in-out_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/25 before:to-transparent',
className className
)} )}
/> />
@@ -1717,7 +1173,7 @@ function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow
return ( return (
<Card> <Card>
<CardHeader <CardHeader
title="사용자 관리" title="사용자/세션"
description="실시간 접근 상태와 세션 제어 항목입니다." description="실시간 접근 상태와 세션 제어 항목입니다."
action={ action={
<button <button
@@ -1728,8 +1184,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>
@@ -1932,28 +1388,7 @@ 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 `${formatBinaryQuantity(metric.usage)} / ${formatBinaryQuantity(metric.capacity)}`; return `${metric.usage ?? '-'} / ${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 }) {
@@ -2262,23 +1697,6 @@ function App() {
if (location.pathname === '/') navigate('/dashboard', { replace: true }); if (location.pathname === '/') navigate('/dashboard', { replace: true });
}, [location.pathname, navigate]); }, [location.pathname, navigate]);
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
fetchCurrentAdmin()
.then((admin) => {
if (cancelled) return;
storeCurrentAdmin(admin);
setCurrentAdmin(admin);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [isAuthenticated]);
useEffect(() => { useEffect(() => {
if (activeView === 'adminManagement' && !isOwner) { if (activeView === 'adminManagement' && !isOwner) {
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
@@ -2309,10 +1727,6 @@ 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 />;
@@ -2336,6 +1750,12 @@ 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':
@@ -2354,8 +1774,12 @@ 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':
@@ -2394,7 +1818,6 @@ function App() {
menuSearch={menuSearch} menuSearch={menuSearch}
onMenuSearchChange={setMenuSearch} onMenuSearchChange={setMenuSearch}
currentAdmin={currentAdmin} currentAdmin={currentAdmin}
onAdminChange={setCurrentAdmin}
onLogout={logout} onLogout={logout}
/> />
<main className={`scrollbar-stable min-h-0 flex-1 overflow-y-auto ${isViewer ? 'viewer-readonly' : ''}`}> <main className={`scrollbar-stable min-h-0 flex-1 overflow-y-auto ${isViewer ? 'viewer-readonly' : ''}`}>
File diff suppressed because it is too large Load Diff
-66
View File
@@ -1,66 +0,0 @@
import { useState } from 'react';
type CommitAuthorUser = {
login?: string;
username?: string;
full_name?: string;
name?: string;
avatar_url?: string;
avatarUrl?: string;
} | null | undefined;
export type CommitAuthorLike = {
commit?: {
author?: {
name?: string;
};
};
author?: CommitAuthorUser;
committer?: CommitAuthorUser;
};
export function commitAuthorName(commit: CommitAuthorLike) {
return commit.author?.login
|| commit.author?.username
|| commit.author?.full_name
|| commit.author?.name
|| commit.commit?.author?.name
|| commit.committer?.login
|| commit.committer?.username
|| commit.committer?.full_name
|| commit.committer?.name
|| '-';
}
function commitAuthorAvatarUrl(commit: CommitAuthorLike) {
return commit.author?.avatar_url
|| commit.author?.avatarUrl
|| commit.committer?.avatar_url
|| commit.committer?.avatarUrl
|| '';
}
export function CommitAuthor({ commit }: { commit: CommitAuthorLike }) {
const [failed, setFailed] = useState(false);
const name = commitAuthorName(commit);
const avatarUrl = commitAuthorAvatarUrl(commit);
const initial = name === '-' ? '?' : name.trim().charAt(0).toUpperCase() || '?';
return (
<span 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>
)}
<span className="truncate font-medium">{name}</span>
</span>
);
}
+17 -110
View File
@@ -6,8 +6,6 @@ export type DataTableColumn<T> = {
key: string; key: string;
header: string; header: string;
className?: string; className?: string;
width?: number;
flexible?: boolean;
cell: (row: T) => ReactNode; cell: (row: T) => ReactNode;
}; };
@@ -18,25 +16,6 @@ export type DataTableFilter<T> = {
getValue: (row: T) => string; getValue: (row: T) => string;
}; };
const spacingWidthPattern = /\bw-(?:\[(\d+)px\]|(\d+))\b/;
const widthClassPattern = /\bw-(?:\[[^\]]+\]|\d+)\b/g;
function getColumnWidth<T>(column: DataTableColumn<T>) {
if (column.key === 'no') return 75;
if (column.width) return column.width;
const widthMatch = column.className?.match(spacingWidthPattern);
if (widthMatch?.[1]) return Number(widthMatch[1]);
if (widthMatch?.[2]) return Number(widthMatch[2]) * 4;
if (column.key === 'actions') return 80;
return 160;
}
function stripWidthClass(className?: string) {
return className?.replace(widthClassPattern, '').replace(/\s+/g, ' ').trim();
}
export function DataTableCard<T>({ export function DataTableCard<T>({
title, title,
description, description,
@@ -49,8 +28,7 @@ export function DataTableCard<T>({
pageSize = 8, pageSize = 8,
minWidth = 980, minWidth = 980,
loading = false, loading = false,
emptyMessage = '데이터가 없습니다.', emptyMessage = '데이터가 없습니다.'
serverPagination
}: { }: {
title: string; title: string;
description?: string; description?: string;
@@ -64,23 +42,12 @@ export function DataTableCard<T>({
minWidth?: number; minWidth?: number;
loading?: boolean; loading?: boolean;
emptyMessage?: string; emptyMessage?: string;
serverPagination?: {
page: number;
total: number;
onPageChange: (page: number) => void;
onSearchChange?: (keyword: string) => void;
filterValues?: Record<string, string>;
onFilterChange?: (key: string, value: string) => void;
};
}) { }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filterValues, setFilterValues] = useState<Record<string, string>>({}); const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const serverMode = Boolean(serverPagination);
const activeFilterValues = serverPagination?.filterValues ?? filterValues;
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
if (serverMode) return rows;
const normalizedSearch = search.trim().toLowerCase(); const normalizedSearch = search.trim().toLowerCase();
return rows.filter((row) => { return rows.filter((row) => {
@@ -88,40 +55,23 @@ export function DataTableCard<T>({
? getSearchText(row).toLowerCase().includes(normalizedSearch) ? getSearchText(row).toLowerCase().includes(normalizedSearch)
: true; : true;
const matchesFilters = filters.every((filter) => { const matchesFilters = filters.every((filter) => {
const selected = activeFilterValues[filter.key]; const selected = filterValues[filter.key];
return !selected || filter.getValue(row) === selected; return !selected || filter.getValue(row) === selected;
}); });
return matchesSearch && matchesFilters; return matchesSearch && matchesFilters;
}); });
}, [activeFilterValues, filters, getSearchText, rows, search, serverMode]); }, [filterValues, filters, getSearchText, rows, search]);
const totalCount = serverPagination?.total ?? filteredRows.length; const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); const currentPage = Math.min(page, totalPages);
const currentPage = Math.min(serverPagination?.page ?? page, totalPages); const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const pageRows = serverMode ? rows : filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
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) {
serverPagination.onFilterChange(key, value);
return;
}
setFilterValues((prev) => ({ ...prev, [key]: value })); setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1); setPage(1);
}; };
const movePage = (nextPage: number) => {
if (serverPagination) {
serverPagination.onPageChange(nextPage);
return;
}
setPage(nextPage);
};
return ( return (
<Card className="min-w-0 overflow-hidden"> <Card className="min-w-0 overflow-hidden">
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
@@ -137,9 +87,7 @@ export function DataTableCard<T>({
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={search} value={search}
onChange={(event) => { onChange={(event) => {
const value = event.target.value; setSearch(event.target.value);
setSearch(value);
serverPagination?.onSearchChange?.(value);
setPage(1); setPage(1);
}} }}
/> />
@@ -151,7 +99,7 @@ export function DataTableCard<T>({
{filter.label} {filter.label}
<select <select
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring" className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
value={activeFilterValues[filter.key] ?? ''} value={filterValues[filter.key] ?? ''}
onChange={(event) => updateFilter(filter.key, event.target.value)} onChange={(event) => updateFilter(filter.key, event.target.value)}
> >
<option value="">전체</option> <option value="">전체</option>
@@ -168,36 +116,11 @@ export function DataTableCard<T>({
</div> </div>
</div> </div>
<div className="max-h-[560px] overflow-x-auto overflow-y-auto"> <div className="max-h-[560px] overflow-x-auto overflow-y-auto">
<table className="w-full table-fixed text-left text-sm" style={{ minWidth }}> <table className="w-full text-left text-sm" style={{ minWidth }}>
<colgroup>
{columns.map((column, columnIndex) => (
<col
key={column.key}
style={
columnIndex === flexibleColumnIndex
? { width: '100%' }
: { width: getColumnWidth(column) }
}
/>
))}
</colgroup>
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground"> <thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr> <tr>
{columns.map((column, columnIndex) => ( {columns.map((column) => (
<th <th key={column.key} className={column.className ?? 'px-5 py-3 font-medium'}>
key={column.key}
className={
column.key === 'no'
? 'py-3 text-center font-medium'
: columnIndex === flexibleColumnIndex
? stripWidthClass(column.className) || 'px-5 py-3 font-medium'
: column.className ?? 'px-5 py-3 font-medium'
}
style={{
...(column.key === 'no' ? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 } : columnIndex === flexibleColumnIndex ? { width: '100%' } : { width: getColumnWidth(column) }),
...(columnIndex === flexibleColumnIndex ? { paddingLeft: 12 } : {}),
}}
>
{column.header} {column.header}
</th> </th>
))} ))}
@@ -206,25 +129,9 @@ export function DataTableCard<T>({
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{!loading && pageRows.map((row) => ( {!loading && pageRows.map((row) => (
<tr key={getRowKey(row)} className="hover:bg-muted/20"> <tr key={getRowKey(row)} className="hover:bg-muted/20">
{columns.map((column, columnIndex) => ( {columns.map((column) => (
<td <td key={column.key} className="px-5 py-3">
key={column.key} {column.cell(row)}
className={column.key === 'no' ? 'py-3 text-center font-medium' : columns[0]?.key === 'no' && columnIndex === 1 ? 'py-3 pl-3 pr-5' : 'px-5 py-3'}
style={
column.key === 'no'
? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 }
: columnIndex === flexibleColumnIndex
? { width: '100%' }
: { width: getColumnWidth(column) }
}
>
{column.key === 'no' ? (
<span className="block w-full text-center font-semibold">
{String((row as { no?: unknown }).no ?? '')}
</span>
) : (
column.cell(row)
)}
</td> </td>
))} ))}
</tr> </tr>
@@ -241,14 +148,14 @@ export function DataTableCard<T>({
</div> </div>
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm"> <div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{loading ? '불러오는 중' : `총 ${totalCount}건 · ${currentPage} / ${totalPages}`} {loading ? '불러오는 중' : `총 ${filteredRows.length}건 · ${currentPage} / ${totalPages}`}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
className="h-8 px-3 text-[13px]" className="h-8 px-3 text-[13px]"
disabled={currentPage <= 1} disabled={currentPage <= 1}
onClick={() => movePage(Math.max(1, currentPage - 1))} onClick={() => setPage((prev) => Math.max(1, prev - 1))}
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
이전 이전
@@ -257,7 +164,7 @@ export function DataTableCard<T>({
variant="outline" variant="outline"
className="h-8 px-3 text-[13px]" className="h-8 px-3 text-[13px]"
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
onClick={() => movePage(Math.min(totalPages, currentPage + 1))} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
> >
다음 다음
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
-48
View File
@@ -1,48 +0,0 @@
import { useState } from 'react';
export type RequesterLike = {
requestedBy?: string | null;
requestedByName?: string | null;
requestedByEmail?: string | null;
requestedByAvatarUrl?: string | null;
createdBy?: string | null;
createdByName?: string | null;
createdByEmail?: string | null;
createdByAvatarUrl?: string | null;
};
export function requesterName(item: RequesterLike) {
return item.requestedByName
|| item.createdByName
|| item.requestedBy
|| item.createdBy
|| item.requestedByEmail
|| item.createdByEmail
|| '-';
}
export function Requester({ item, label = '작업자' }: { item: RequesterLike; label?: string }) {
const [failed, setFailed] = useState(false);
const name = requesterName(item);
const avatarUrl = item.requestedByAvatarUrl || item.createdByAvatarUrl || '';
const initial = name.trim().charAt(0).toUpperCase() || '?';
return (
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle text-muted-foreground">
<span className="leading-none">{label}</span>
{name === '-' ? null : avatarUrl && !failed ? (
<img
src={avatarUrl}
alt=""
className="size-4 shrink-0 rounded-full border border-border bg-muted object-cover"
onError={() => setFailed(true)}
/>
) : (
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[9px] font-semibold leading-none text-muted-foreground">
{initial}
</span>
)}
<b className="truncate leading-none text-foreground">{name}</b>
</span>
);
}
+776
View File
@@ -0,0 +1,776 @@
import {
Bell,
Boxes,
ChartNoAxesColumnIncreasing,
FileKey,
HardDrive,
KeyRound,
LayoutDashboard,
Megaphone,
Network,
Rocket,
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: Rocket,
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: ShieldCheck,
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개' }
];
+21 -24
View File
@@ -1,15 +1,12 @@
import { import {
BookOpen,
Boxes, Boxes,
ChartNoAxesColumnIncreasing, ChartNoAxesColumnIncreasing,
FileKey, FileKey,
HardDrive, HardDrive,
LayoutDashboard, LayoutDashboard,
Megaphone, Megaphone,
MonitorPlay,
MonitorUp,
Network, Network,
ScrollText, Rocket,
ServerCog, ServerCog,
ShieldCheck, ShieldCheck,
Users Users
@@ -38,19 +35,9 @@ 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: MonitorPlay, icon: Rocket,
children: [ children: [
{ label: '소스 관리', path: '/launcher/sources' }, { label: '소스 관리', path: '/launcher/sources' },
{ label: '빌드 관리', path: '/launcher/builds' }, { label: '빌드 관리', path: '/launcher/builds' },
@@ -82,18 +69,30 @@ 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: MonitorUp, icon: ShieldCheck,
children: [ children: [
{ label: '플랫폼 등록', path: '/agents/registration' }, { label: '장비 등록', path: '/agents/registration' },
{ label: '연결 제어', path: '/runtime/devices' }, { label: '장비 제어', path: '/runtime/devices' },
{ label: 'SSH 키', path: '/agents/ssh-keys' }, { label: '설치 상태', path: '/agents/install-status' },
{ 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,
@@ -111,11 +110,9 @@ 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 }
] ]
}, },
{ {
-1
View File
@@ -3,7 +3,6 @@ export type CurrentAdminLike = {
email?: string; email?: string;
name?: string; name?: string;
role?: string; role?: string;
avatarUrl?: string | null;
appPermissions?: string[]; appPermissions?: string[];
}; };
+103 -355
View File
@@ -1,31 +1,18 @@
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, Trash2 } from 'lucide-react'; import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { 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';
@@ -331,8 +318,6 @@ 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;
@@ -355,20 +340,6 @@ 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
@@ -401,30 +372,13 @@ export function TailscaleStatusPage() {
</Card> </Card>
) : null} ) : null}
<div className="mt-4"> <div className="mt-4">
<TailscaleNodeTable nodes={nodes} loading={loading} onDelete={setDeleteTarget} /> <TailscaleNodeTable nodes={nodes} loading={loading} />
</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, onDelete }: { nodes: TailscaleNode[]; loading: boolean; onDelete: (node: TailscaleNode) => void }) { function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
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));
@@ -453,18 +407,17 @@ function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode
<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={8} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td> <td colSpan={7} 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={8} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td> <td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
</tr> </tr>
) : null} ) : null}
{pageNodes.map((node) => ( {pageNodes.map((node) => (
@@ -476,17 +429,6 @@ function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode
<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>
@@ -840,238 +782,81 @@ export function RuntimeDevicesPage() {
<AgentTable <AgentTable
title="장비 목록" title="장비 목록"
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다." description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
withCommands
/> />
</> </>
); );
} }
type NumberedPlatformDevice = PlatformDevice & { no: number }; function AgentTable({ title, description, withCommands = false }: { title: string; description: string; withCommands?: boolean }) {
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 (
<DataTableCard <Card className="min-w-0 overflow-hidden">
title={title} <CardHeader
description={description} title={title}
rows={rows} description={description}
columns={columns} action={
searchPlaceholder="플랫폼명, 계정, 워크스페이스 검색" withCommands ? (
getSearchText={(row) => `${row.hostname} ${row.tailscaleHostname ?? ''} ${row.accountEmail} ${row.workspaceRoot ?? ''} ${row.sshUser ?? ''}`} <div className="flex gap-2">
getRowKey={(row) => row.id} <Button variant="outline" className="h-8 px-3 text-[13px]">
pageSize={6} <SlidersHorizontal className="size-4" />
minWidth={1080} 상태 필터
loading={loading} </Button>
emptyMessage="등록된 플랫폼이 없습니다." <Button className="h-8 px-3 text-[13px]">
/> <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="플랫폼 관리" title="플랫폼 등록" description="에이전트 설치에 사용할 사용자별 6자리 등록 키를 발급하고 관리합니다." /> <PageTitle eyebrow="Agent" title="장비 등록" description="새 장비에 설치할 Agent 등록 토큰과 연결 정보를 발급합니다." />
<Card className="mb-4"> <AgentPlaceholder title="장비 등록 요청" description="Agent 등록 요청과 토큰 발급 상태를 확인합니다." />
<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>
</> </>
); );
} }
@@ -1079,74 +864,26 @@ export function AgentRegistrationPage() {
export function AgentInstallStatusPage() { export function AgentInstallStatusPage() {
return ( return (
<> <>
<PageTitle eyebrow="플랫폼 관리" title="연결 상태" description="플랫폼별 Agent 설치와 heartbeat 상태를 확인합니다." /> <PageTitle eyebrow="Agent" title="설치 상태" description="장비별 Agent 설치와 heartbeat 상태를 확인합니다." />
<AgentTable title="연결 상태" description="플랫폼별 Agent 연결 상태를 확인합니다." /> <AgentPlaceholder title="설치 상태" description="설치 완료, 대기, 실패 상태를 확인합니다." />
</> </>
); );
} }
export function AgentSshKeysPage() { export function AgentSshKeysPage() {
const [keys, setKeys] = useState<PlatformSshKey[]>([]);
const [loading, setLoading] = useState(true);
const [selectedKey, setSelectedKey] = useState<PlatformSshKey | null>(null);
useEffect(() => {
loadPlatformSshKeys()
.then(setKeys)
.catch(() => setKeys([]))
.finally(() => setLoading(false));
}, []);
const rows = useMemo<NumberedPlatformSshKey[]>(
() => keys.map((key, index) => ({ ...key, no: keys.length - index })),
[keys]
);
return ( return (
<> <>
<PageTitle eyebrow="플랫폼 관리" title="SSH 키" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." /> <PageTitle eyebrow="Agent" title="SSH 키 배포" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
<DataTableCard <AgentPlaceholder title="SSH 키 배포" description="장비별 SSH 키 배포 상태입니다." />
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> }, export function AgentWorkspacePage() {
{ key: 'account', header: '계정', flexible: true, cell: (row) => row.accountEmail }, return (
{ key: 'label', header: '이름', width: 180, cell: (row) => row.label || '-' }, <>
{ key: 'type', header: '유형', width: 140, cell: (row) => row.keyType || '-' }, <PageTitle eyebrow="Agent" title="Workspace 설정" description="장비별 작업 디렉터리와 동기화 정책을 관리합니다." />
{ key: 'updated', header: '최근 등록', width: 180, cell: (row) => formatTailscaleLastSeen(row.lastProvisionedAt) }, <AgentPlaceholder title="Workspace 설정" description="계정별 workspace 경로와 동기화 상태입니다." />
{
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>
</> </>
); );
} }
@@ -1165,7 +902,7 @@ export function AgentCommandsPage() {
return ( return (
<> <>
<PageTitle eyebrow="플랫폼 관리" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." /> <PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
<DataTableCard <DataTableCard
title="최근 제어 작업" title="최근 제어 작업"
rows={runtimeCommands} rows={runtimeCommands}
@@ -1178,6 +915,17 @@ 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]>>>(
() => [ () => [
-78
View File
@@ -1,78 +0,0 @@
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' });
}
+20 -299
View File
@@ -1,22 +1,20 @@
import { useEffect, useState, type MouseEvent } from 'react'; import { useEffect, useState, type MouseEvent } from 'react';
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2, UserCog } from 'lucide-react'; import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api'; import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api';
import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin, isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions'; import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin } from '../../lib/admin-app-permissions';
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot'; import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app'; import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime'; import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
import { LoadingModal } from '../operations/operation-utils'; import { LoadingModal } from '../operations/operation-utils';
import { loadAdmins, updateAdminAppPermissions, type AdminAccount } from '../operations/operations-api';
type ComponentPortForm = { type ComponentPortForm = {
name: string; name: string;
port: string; port: string;
targetPort: string; targetPort: string;
nodePort: string;
protocol: string; protocol: string;
}; };
@@ -31,7 +29,6 @@ const emptyComponentPort = (): ComponentPortForm => ({
name: '', name: '',
port: '', port: '',
targetPort: '', targetPort: '',
nodePort: '',
protocol: 'TCP' protocol: 'TCP'
}); });
@@ -88,28 +85,6 @@ function formatStatus(status?: string | null, launcherLinked?: boolean) {
return status ? statusMap[status] ?? status : '-'; return status ? statusMap[status] ?? status : '-';
} }
function AppAdminAvatar({ admin }: { admin: AdminAccount }) {
const [failed, setFailed] = useState(false);
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
if (admin.avatarUrl && !failed) {
return (
<img
src={admin.avatarUrl}
alt=""
className="size-8 shrink-0 rounded-full border border-border bg-muted object-cover"
onError={() => setFailed(true)}
/>
);
}
return (
<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
{initial}
</span>
);
}
function loadApps() { function loadApps() {
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() }) return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
.then((response) => { .then((response) => {
@@ -277,7 +252,6 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
name: port.name ?? '', name: port.name ?? '',
port: port.port ? String(port.port) : '', port: port.port ? String(port.port) : '',
targetPort: port.targetPort ? String(port.targetPort) : '', targetPort: port.targetPort ? String(port.targetPort) : '',
nodePort: port.nodePort ? String(port.nodePort) : '',
protocol: port.protocol ?? 'TCP' protocol: port.protocol ?? 'TCP'
})); }));
} }
@@ -287,7 +261,6 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
name: 'http', name: 'http',
port: String(component.port), port: String(component.port),
targetPort: String(component.targetPort ?? component.port), targetPort: String(component.targetPort ?? component.port),
nodePort: '',
protocol: 'TCP' protocol: 'TCP'
}]; }];
} }
@@ -298,20 +271,7 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
function formatComponentPorts(component: ApiAppComponent) { function formatComponentPorts(component: ApiAppComponent) {
const ports = componentPortForms(component).filter((port) => port.port); const ports = componentPortForms(component).filter((port) => port.port);
if (ports.length === 0) return '-'; if (ports.length === 0) return '-';
return ports.map((port) => { return ports.map((port) => `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}`).join(', ');
const nodePort = port.nodePort ? ` / node ${port.nodePort}` : '';
return `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}${nodePort}`;
}).join(', ');
}
function appKeyFromName(name: string) {
return name
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
} }
export function AppManagementPage() { export function AppManagementPage() {
@@ -357,13 +317,6 @@ export function AppManagementPage() {
deleteKubernetesResources: false, deleteKubernetesResources: false,
deletePersistentVolumes: false deletePersistentVolumes: false
}); });
const [isAppAdminsOpen, setIsAppAdminsOpen] = useState(false);
const [appAdmins, setAppAdmins] = useState<AdminAccount[]>([]);
const [appAdminPermissions, setAppAdminPermissions] = useState<Record<string, string[]>>({});
const [appAdminsLoading, setAppAdminsLoading] = useState(false);
const [appAdminSavingId, setAppAdminSavingId] = useState('');
const [appAdminSearch, setAppAdminSearch] = useState('');
const [appAdminConfirm, setAppAdminConfirm] = useState<{ admin: AdminAccount; allowed: boolean } | null>(null);
const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null); const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null);
const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]); const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
const [workloadActionKey, setWorkloadActionKey] = useState(''); const [workloadActionKey, setWorkloadActionKey] = useState('');
@@ -381,20 +334,6 @@ export function AppManagementPage() {
const workloads = runtimeResources.filter(isWorkloadResource); const workloads = runtimeResources.filter(isWorkloadResource);
const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName); const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName);
const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : ''; const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : '';
const selectedAppAuthorizedAdmins = selectedApp
? appAdmins.filter((admin) => isOwnerAdmin(admin) || (appAdminPermissions[admin.id] ?? admin.appPermissions ?? []).includes(selectedApp.id))
: [];
const appAdminAddOptions = selectedApp
? appAdmins.filter((admin) => (
admin.isActive
&& !isOwnerAdmin(admin)
&& !(appAdminPermissions[admin.id] ?? admin.appPermissions ?? []).includes(selectedApp.id)
))
: [];
const appAdminSearchKeyword = appAdminSearch.trim().toLowerCase();
const appAdminSearchResults = appAdminSearchKeyword.length >= 2
? appAdminAddOptions.filter((admin) => `${admin.name} ${admin.email} ${admin.role}`.toLowerCase().includes(appAdminSearchKeyword)).slice(0, 8)
: [];
const canDeleteVolume = Boolean( const canDeleteVolume = Boolean(
deletingVolumeWorkload && deletingVolumeWorkload &&
selectedVolumeClaimName && selectedVolumeClaimName &&
@@ -569,13 +508,6 @@ export function AppManagementPage() {
}); });
}; };
const changeNewAppName = (name: string) => {
const appKey = appKeyFromName(name);
setNewAppName(name);
setNewAppKey(appKey);
setNewAppNamespace(appKey ? `stdo-${appKey}` : '');
};
const createApp = () => { const createApp = () => {
if (!newAppKey.trim() || !newAppName.trim()) return; if (!newAppKey.trim() || !newAppName.trim()) return;
@@ -671,7 +603,6 @@ export function AppManagementPage() {
name: item.name.trim() || undefined, name: item.name.trim() || undefined,
port: item.port ? Number(item.port) : undefined, port: item.port ? Number(item.port) : undefined,
targetPort: item.targetPort ? Number(item.targetPort) : item.port ? Number(item.port) : undefined, targetPort: item.targetPort ? Number(item.targetPort) : item.port ? Number(item.port) : undefined,
nodePort: componentServiceType === 'NodePort' && item.nodePort ? Number(item.nodePort) : undefined,
protocol: item.protocol || 'TCP' protocol: item.protocol || 'TCP'
})) }))
.filter((item) => item.port); .filter((item) => item.port);
@@ -928,57 +859,6 @@ export function AppManagementPage() {
})); }));
}; };
const openAppAdmins = () => {
if (!selectedApp) return;
setIsAppAdminsOpen(true);
setAppAdminsLoading(true);
setAppAdminSearch('');
loadAdmins()
.then((admins) => {
setAppAdmins(admins);
setAppAdminPermissions(Object.fromEntries(admins.map((admin) => [admin.id, admin.appPermissions ?? []])));
})
.catch((error: Error) => {
setAppAdmins([]);
setAppAdminPermissions({});
toast({ type: 'error', title: '관리자 조회 실패', message: error.message });
})
.finally(() => setAppAdminsLoading(false));
};
const requestAppAdminAccess = (admin: AdminAccount, allowed: boolean) => {
if (isOwnerAdmin(admin) || appAdminSavingId) return;
setAppAdminConfirm({ admin, allowed });
};
const setAppAdminAccess = (admin: AdminAccount, allowed: boolean) => {
if (!selectedApp || isOwnerAdmin(admin) || appAdminSavingId) return;
const currentAppIds = appAdminPermissions[admin.id] ?? admin.appPermissions ?? [];
const nextAppIds = allowed
? [...new Set([...currentAppIds, selectedApp.id])]
: currentAppIds.filter((appId) => appId !== selectedApp.id);
setAppAdminSavingId(admin.id);
updateAdminAppPermissions(admin.id, nextAppIds)
.then((result) => {
const savedAppIds = result.appIds ?? nextAppIds;
setAppAdminPermissions((current) => ({ ...current, [admin.id]: savedAppIds }));
setAppAdmins((current) => current.map((item) => item.id === admin.id ? { ...item, appPermissions: savedAppIds } : item));
setAdminAppPermissions(admin, savedAppIds);
setAppAdminSearch('');
setAppAdminConfirm(null);
toast({
type: 'success',
title: allowed ? '관리자 추가' : '관리자 해제',
message: `${admin.name} 관리자 권한이 저장되었습니다.`
});
})
.catch((error: Error) => {
toast({ type: 'error', title: '앱 관리자 저장 실패', message: error.message });
})
.finally(() => setAppAdminSavingId(''));
};
return ( return (
<> <>
<PageTitle <PageTitle
@@ -986,14 +866,9 @@ export function AppManagementPage() {
title="앱 관리" title="앱 관리"
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다." description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다."
actions={ actions={
<div className="flex items-center gap-2"> <Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={openAppAdmins}> <Plus className="size-4" />앱 등록
<UserCog className="size-4" />관리자 </Button>
</Button>
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
<Plus className="size-4" />앱 등록
</Button>
</div>
} }
/> />
@@ -1106,7 +981,7 @@ export function AppManagementPage() {
title="앱 구성요소" title="앱 구성요소"
description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다." description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다."
action={ action={
<Button className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={() => setIsComponentOpen(true)}> <Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={() => setIsComponentOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
추가 추가
</Button> </Button>
@@ -1291,159 +1166,24 @@ export function AppManagementPage() {
) : null} ) : null}
</div> </div>
{isAppAdminsOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
<div className="flex max-h-[86vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-3">
<h2 className="text-sm font-semibold">관리자</h2>
<p className="mt-1 text-xs text-muted-foreground">
{selectedApp?.name ?? '-'} · {selectedApp?.key ?? '-'} 앱에 접근할 수 있는 관리자입니다.
</p>
</div>
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5">
<div className="rounded-md border border-border p-3">
<p className="text-xs font-medium text-muted-foreground">관리자 추가</p>
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={appAdminSearch}
onChange={(event) => setAppAdminSearch(event.target.value)}
placeholder="이름 또는 이메일 입력"
disabled={appAdminsLoading || appAdminAddOptions.length === 0 || Boolean(appAdminSavingId)}
/>
{appAdminSearchKeyword.length >= 2 ? (
<div className="mt-2 overflow-hidden rounded-md border border-border">
{appAdminSearchResults.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">검색 결과가 없습니다.</div>
) : (
appAdminSearchResults.map((admin) => (
<div
key={admin.id}
className="flex w-full items-center justify-between gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-muted/20"
>
<span className="flex min-w-0 items-center gap-3">
<AppAdminAvatar admin={admin} />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-foreground">{admin.name}</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{admin.email}</span>
</span>
</span>
<div className="flex shrink-0 items-center gap-2">
<Badge tone="blue">{admin.role}</Badge>
<Button
variant="outline"
className="h-7 px-2 text-xs"
disabled={Boolean(appAdminSavingId)}
onClick={() => requestAppAdminAccess(admin, true)}
>
선택
</Button>
</div>
</div>
))
)}
</div>
) : null}
</div>
<div className="overflow-hidden rounded-md border border-border">
{appAdminsLoading ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">관리자 목록을 불러오는 중입니다.</div>
) : selectedAppAuthorizedAdmins.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">권한이 있는 관리자가 없습니다.</div>
) : (
selectedAppAuthorizedAdmins.map((admin) => {
const owner = isOwnerAdmin(admin);
const saving = appAdminSavingId === admin.id;
return (
<div key={admin.id} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
<div className="flex min-w-0 items-center gap-3">
<AppAdminAvatar admin={admin} />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-foreground">{admin.name}</p>
<Badge tone={owner ? 'green' : 'blue'}>{owner ? '오너' : '관리자'}</Badge>
{!admin.isActive ? <Badge tone="red">비활성</Badge> : null}
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">{admin.email}</p>
</div>
</div>
{owner ? (
<span className="shrink-0 text-xs text-muted-foreground">전체 앱 접근</span>
) : (
<Button
variant="outline"
className="h-8 shrink-0 border-rose-500/50 px-3 text-[13px] text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
disabled={saving || Boolean(appAdminSavingId)}
onClick={() => requestAppAdminAccess(admin, false)}
>
{saving ? '저장 중' : '해제'}
</Button>
)}
</div>
);
})
)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
<Button variant="outline" onClick={() => setIsAppAdminsOpen(false)} disabled={Boolean(appAdminSavingId)}>
닫기
</Button>
</div>
</div>
</div>
) : null}
<Modal
open={Boolean(appAdminConfirm)}
title={appAdminConfirm?.allowed ? '관리자 권한 추가' : '관리자 권한 해제'}
onClose={() => {
if (!appAdminSavingId) setAppAdminConfirm(null);
}}
footer={
<>
<Button variant="outline" disabled={Boolean(appAdminSavingId)} onClick={() => setAppAdminConfirm(null)}>취소</Button>
<Button
className={appAdminConfirm?.allowed ? undefined : 'border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'}
disabled={!appAdminConfirm || Boolean(appAdminSavingId)}
onClick={() => appAdminConfirm && setAppAdminAccess(appAdminConfirm.admin, appAdminConfirm.allowed)}
>
{appAdminSavingId ? '저장 중' : appAdminConfirm?.allowed ? '추가' : '해제'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
<p className="text-sm font-semibold text-foreground">{appAdminConfirm?.admin.name ?? '-'}</p>
<p className="mt-1 text-xs text-muted-foreground">{appAdminConfirm?.admin.email ?? '-'}</p>
</div>
<p className="text-sm leading-6 text-muted-foreground">
{appAdminConfirm?.allowed
? `${selectedApp?.name ?? '-'} 앱 접근 권한을 추가합니다.`
: `${selectedApp?.name ?? '-'} 앱 접근 권한을 해제합니다.`}
</p>
</div>
</Modal>
{isCreateAppOpen ? ( {isCreateAppOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl"> <div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
<div className="border-b border-border px-5 py-3"> <div className="border-b border-border px-5 py-3">
<h2 className="text-sm font-semibold">앱 등록</h2> <h2 className="text-sm font-semibold">앱 그룹 등록</h2>
</div> </div>
<div className="space-y-4 px-5 py-5"> <div className="space-y-4 px-5 py-5">
<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">앱 Key</span>
<input value={newAppName} onChange={(event) => changeNewAppName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" /> <input value={newAppKey} onChange={(event) => setNewAppKey(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
</label> </label>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">앱 Key</span> <span className="text-xs font-medium text-muted-foreground">앱 이름</span>
<input value={newAppKey} readOnly className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 font-mono text-sm text-muted-foreground outline-none" /> <input value={newAppName} onChange={(event) => setNewAppName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
</label> </label>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span> <span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
<input value={newAppNamespace} readOnly className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 font-mono text-sm text-muted-foreground outline-none" /> <input value={newAppNamespace} onChange={(event) => setNewAppNamespace(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
</label> </label>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">설명</span> <span className="text-xs font-medium text-muted-foreground">설명</span>
@@ -1475,11 +1215,11 @@ export function AppManagementPage() {
/> />
</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">구성요소명</span>
<input value={componentName} onChange={(event) => setComponentName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" /> <input value={componentName} onChange={(event) => setComponentName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
</label> </label>
<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">유형</span>
<select <select
value={componentType} value={componentType}
onChange={(event) => { onChange={(event) => {
@@ -1506,7 +1246,7 @@ export function AppManagementPage() {
</label> </label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<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">이미지 소스</span>
<select <select
value={componentImageSource} value={componentImageSource}
onChange={(event) => { onChange={(event) => {
@@ -1530,7 +1270,7 @@ export function AppManagementPage() {
</select> </select>
</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">워크로드</span>
<select <select
value={componentWorkloadType} value={componentWorkloadType}
onChange={(event) => { onChange={(event) => {
@@ -1571,7 +1311,7 @@ export function AppManagementPage() {
</> </>
) : ( ) : (
<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">이미지명</span>
<input <input
value={componentExternalImage} value={componentExternalImage}
onChange={(event) => setComponentExternalImage(event.target.value)} onChange={(event) => setComponentExternalImage(event.target.value)}
@@ -1603,15 +1343,7 @@ export function AppManagementPage() {
</div> </div>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{componentPorts.map((item, index) => ( {componentPorts.map((item, index) => (
<div <div key={index} className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,0.7fr)_2.5rem]">
key={index}
className={cn(
'grid min-w-0 gap-2',
componentServiceType === 'NodePort'
? 'sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.65fr)_2.5rem]'
: 'sm:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,0.7fr)_2.5rem]'
)}
>
<input <input
value={item.name} value={item.name}
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, name: event.target.value } : port))} onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, name: event.target.value } : port))}
@@ -1634,17 +1366,6 @@ export function AppManagementPage() {
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
placeholder="컨테이너 포트" placeholder="컨테이너 포트"
/> />
{componentServiceType === 'NodePort' ? (
<input
type="number"
min="30000"
max="32767"
value={item.nodePort}
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, nodePort: event.target.value } : port))}
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
placeholder="노드 포트"
/>
) : null}
<select <select
value={item.protocol} value={item.protocol}
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, protocol: event.target.value } : port))} onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, protocol: event.target.value } : port))}
+3 -70
View File
@@ -1,13 +1,12 @@
import { useEffect, useMemo, useState, type MouseEvent } from 'react'; import { useEffect, useMemo, useState, type MouseEvent } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2, Upload } from 'lucide-react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } 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 { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; 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, createQuickDeployment, deleteBuildJob, listBuildJobsPage, listSources } from './application-api'; import { cancelBuildJob, createBuildJob, 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 +28,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) : '<commit-sha>'; const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
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,12 +63,6 @@ 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;
@@ -101,12 +94,9 @@ 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);
@@ -285,29 +275,6 @@ 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
@@ -416,7 +383,6 @@ export function BuildManagementPage() {
<span className="font-medium">{job.component.name}</span> <span className="font-medium">{job.component.name}</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">
<Requester item={job} />
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span> <span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span> <span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span> <span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
@@ -436,11 +402,6 @@ 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"
@@ -543,34 +504,6 @@ 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="빌드 실행 확인"
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { Requester } from '../../components/requester';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import type { AppRelease, DeploymentJob } from '../../types/application'; import type { AppRelease, DeploymentJob } from '../../types/application';
@@ -36,13 +35,11 @@ type ImageTags = {
artifacts?: Record<string, string>; artifacts?: Record<string, string>;
}; };
function releaseArtifacts(release?: AppRelease | null, appName?: string) { function releaseArtifacts(release?: AppRelease | null) {
const imageTags = release?.imageTags as ImageTags | null | undefined; const imageTags = release?.imageTags as ImageTags | null | undefined;
const entries = Object.entries(imageTags?.images ?? {}).filter(([, image]) => ( const entries = Object.entries(imageTags?.images ?? {});
!/^(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 ?? appName ?? 'artifact', release.artifact]]; if (release?.artifact) return [[release.app.name, release.artifact]];
return []; return [];
} }
@@ -79,11 +76,6 @@ 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);
@@ -165,12 +157,6 @@ 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);
}; };
@@ -178,22 +164,8 @@ 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({ const result = await createDeployment({ releaseId, environment: 'prod', namespace, updateLauncherAppVersion });
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);
@@ -263,7 +235,7 @@ export function DeploymentManagementPage() {
</div> </div>
) : null} ) : null}
{pagedDeployments.map((deployment, index) => { {pagedDeployments.map((deployment, index) => {
const artifacts = releaseArtifacts(deployment.release, deployment.app?.name); const artifacts = releaseArtifacts(deployment.release);
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">
@@ -272,12 +244,12 @@ 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>
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span> <span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
<Requester item={deployment} label="요청자" /> <span className="text-muted-foreground">요청자 <b className="text-foreground">{deployment.requestedBy || 'admin'}</b></span>
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span> <span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
</div> </div>
</div> </div>
@@ -384,60 +356,6 @@ 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"
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play } 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 { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import type { AppRelease, RuntimeControlJob } from '../../types/application'; import type { AppRelease, RuntimeControlJob } from '../../types/application';
@@ -313,7 +312,6 @@ export function MigrationManagementPage() {
<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">{releaseVersion}</b></span> <span className="text-muted-foreground">릴리즈 <b className="text-foreground">{releaseVersion}</b></span>
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span> <span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
<Requester item={job} />
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span> <span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span> <span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
</div> </div>
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { Requester } from '../../components/requester';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import type { AppRelease, BuildJob } from '../../types/application'; import type { AppRelease, BuildJob } from '../../types/application';
@@ -30,43 +29,17 @@ function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'bl
type ImageTags = { type ImageTags = {
images?: Record<string, string>; images?: Record<string, string>;
artifacts?: Record<string, string>; artifacts?: Record<string, string>;
buildJobs?: Array<{
component?: string;
type?: string;
}>;
error?: string; error?: string;
promotion?: { promotion?: {
sourceBuildJobIds?: string[]; sourceBuildJobIds?: string[];
}; };
}; };
function releaseBuildComponentTypes(imageTags: ImageTags | null | undefined) {
const map = new Map<string, string>();
for (const job of imageTags?.buildJobs ?? []) {
if (job.component && job.type) map.set(job.component, job.type);
}
return map;
}
function isRuntimeOnlyImage(value: string) {
return /^(postgres|redis|mysql|mariadb|mongo|nginx|rabbitmq)(:|@)/i.test(value.trim());
}
function isReleaseArtifactVisible(componentName: string, value: string, componentTypes: Map<string, string>) {
const type = componentTypes.get(componentName);
return type !== 'DATABASE' && type !== 'MIGRATION' && !isRuntimeOnlyImage(value);
}
function releaseArtifacts(release: AppRelease) { function releaseArtifacts(release: AppRelease) {
const imageTags = release.imageTags as ImageTags | null | undefined; const imageTags = release.imageTags as ImageTags | null | undefined;
const componentTypes = releaseBuildComponentTypes(imageTags);
const values = [ const values = [
...Object.entries(imageTags?.images ?? {}) ...Object.values(imageTags?.images ?? {}),
.filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes)) ...Object.values(imageTags?.artifacts ?? {})
.map(([, value]) => value),
...Object.entries(imageTags?.artifacts ?? {})
.filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes))
.map(([, value]) => value)
].filter(Boolean); ].filter(Boolean);
if (values.length) return values; if (values.length) return values;
@@ -141,32 +114,9 @@ function newestBuildVersion(builds: BuildJob[]) {
.find((value): value is string => Boolean(value)) ?? ''; .find((value): value is string => Boolean(value)) ?? '';
} }
function incrementPatchVersion(value: string) {
const match = value.trim().match(/^(v?)(\d+)\.(\d+)\.(\d+)(.*)$/);
if (!match) return value;
const [, prefix, major, minor, patch, suffix] = match;
return `${prefix}${major}.${minor}.${Number(patch) + 1}${suffix}`;
}
function newestReleaseVersion(releases: AppRelease[], appId: string) {
return releases
.filter((release) => release.app.id === appId && release.version)
.sort((a, b) => releaseTime(b) - releaseTime(a))
.map((release) => release.version)
.find((value): value is string => Boolean(value)) ?? '';
}
function nextReleaseVersion(releases: AppRelease[], appId: string, builds: BuildJob[]) {
const latestReleaseVersion = newestReleaseVersion(releases, appId);
if (latestReleaseVersion) return incrementPatchVersion(latestReleaseVersion);
return newestBuildVersion(builds);
}
function buildCount(release: AppRelease) { function buildCount(release: AppRelease) {
const imageTags = release.imageTags as ImageTags | null | undefined; const imageTags = release.imageTags as ImageTags | null | undefined;
const componentTypes = releaseBuildComponentTypes(imageTags); const count = Object.keys(imageTags?.images ?? {}).length + Object.keys(imageTags?.artifacts ?? {}).length;
const count = Object.entries(imageTags?.images ?? {}).filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes)).length
+ Object.entries(imageTags?.artifacts ?? {}).filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes)).length;
return count || (release.artifact ? 1 : 0); return count || (release.artifact ? 1 : 0);
} }
@@ -316,7 +266,7 @@ export function ReleaseManagementPage() {
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === nextAppId); const appBuilds = candidateBuilds.filter((build) => build.component.app.id === nextAppId);
const latestBuilds = latestCandidateBuilds(appBuilds); const latestBuilds = latestCandidateBuilds(appBuilds);
setSelectedBuildIds(latestBuildIdsByComponent(latestBuilds)); setSelectedBuildIds(latestBuildIdsByComponent(latestBuilds));
setVersion(nextReleaseVersion(releases, nextAppId, appBuilds)); setVersion((prev) => prev || newestBuildVersion(appBuilds));
setReleaseModalOpen(true); setReleaseModalOpen(true);
}; };
@@ -426,7 +376,6 @@ export function ReleaseManagementPage() {
</Button> </Button>
</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">
<Requester item={release} />
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span> <span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span> <span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span> <span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
@@ -507,27 +456,19 @@ export function ReleaseManagementPage() {
<Modal open={releaseModalOpen} title="릴리즈 생성" onClose={() => setReleaseModalOpen(false)} footer={<><Button variant="outline" onClick={() => setReleaseModalOpen(false)}>취소</Button><Button disabled={!version || selectedBuildIds.length === 0} onClick={submit}>생성</Button></>}> <Modal open={releaseModalOpen} title="릴리즈 생성" onClose={() => setReleaseModalOpen(false)} footer={<><Button variant="outline" onClick={() => setReleaseModalOpen(false)}>취소</Button><Button disabled={!version || selectedBuildIds.length === 0} onClick={submit}>생성</Button></>}>
<div className="space-y-3"> <div className="space-y-3">
<label className="block"> <select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => {
<span className="text-xs font-medium text-muted-foreground">앱 <b className="text-foreground">*</b></span> selectApp(event.target.value);
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => { const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
selectApp(event.target.value); setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value); setVersion(newestBuildVersion(appBuilds));
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds)); }}>
setVersion(nextReleaseVersion(releases, event.target.value, appBuilds)); {candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
}}> </select>
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)} <input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
</select>
</label>
<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={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
</label>
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground"> <div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다. 선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
</div> </div>
<div> <div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
<p className="text-xs font-medium text-muted-foreground">빌드 선택 <b className="text-foreground">*</b></p>
<div className="mt-2 max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
{modalBuilds.length === 0 ? ( {modalBuilds.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-muted-foreground"> <div className="px-2 py-8 text-center text-sm text-muted-foreground">
등록 가능한 새 빌드 결과가 없습니다. 등록 가능한 새 빌드 결과가 없습니다.
@@ -551,7 +492,6 @@ export function ReleaseManagementPage() {
</label> </label>
); );
})} })}
</div>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -381,7 +381,7 @@ export function RepositoryManagementPage() {
</div> </div>
<div className="space-y-4 px-5 py-5"> <div className="space-y-4 px-5 py-5">
<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">저장소명</span>
<div className="mt-2 flex h-9 overflow-hidden rounded-md border border-border bg-background focus-within:border-primary"> <div className="mt-2 flex h-9 overflow-hidden rounded-md border border-border bg-background focus-within:border-primary">
<span className="flex h-full shrink-0 items-center border-r border-border bg-muted px-3 text-xs font-semibold text-muted-foreground"> <span className="flex h-full shrink-0 items-center border-r border-border bg-muted px-3 text-xs font-semibold text-muted-foreground">
{giteaAppOrg}/ {giteaAppOrg}/
@@ -2,7 +2,6 @@ import { Copy, FileText, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { CommitAuthor } from '../../components/commit-author';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import type { ApiApp, ApiAppComponent } from '../../types/runtime'; import type { ApiApp, ApiAppComponent } from '../../types/runtime';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
@@ -559,7 +558,7 @@ export function SourceManagementPage() {
) : null} ) : null}
{!commitsLoading && !commitsError ? commits.map((commit) => ( {!commitsLoading && !commitsError ? commits.map((commit) => (
<tr key={commit.sha} className="border-b border-border last:border-0"> <tr key={commit.sha} className="border-b border-border last:border-0">
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td> <td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td> <td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td> <td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td> <td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
@@ -574,7 +573,7 @@ export function SourceManagementPage() {
</table> </table>
</div> </div>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm"> <div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground">
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`} {totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+2 -31
View File
@@ -12,14 +12,6 @@ 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?: {
@@ -38,16 +30,12 @@ export type GiteaCommit = {
username?: string; username?: string;
full_name?: string; full_name?: string;
name?: string; name?: string;
avatar_url?: string;
avatarUrl?: string;
} | null; } | null;
committer?: { committer?: {
login?: string; login?: string;
username?: string; username?: string;
full_name?: string; full_name?: string;
name?: string; name?: string;
avatar_url?: string;
avatarUrl?: string;
} | null; } | null;
created?: string; created?: string;
}; };
@@ -88,7 +76,7 @@ export function createComponent(
buildTarget?: string; buildTarget?: string;
port?: number; port?: number;
targetPort?: number; targetPort?: number;
ports?: Array<{ name?: string; port?: number; targetPort?: number; nodePort?: number; protocol?: string }>; ports?: Array<{ name?: string; port?: number; targetPort?: number; protocol?: string }>;
} }
) { ) {
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, { return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
@@ -238,24 +226,7 @@ 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: { export function createDeployment(input: { releaseId: string; environment: string; namespace: string; updateLauncherAppVersion?: boolean }) {
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)
+2 -5
View File
@@ -29,9 +29,6 @@ export function CommonAppBuildPage() {
[pipelines] [pipelines]
); );
const selectedPipeline = pipelines.find((pipeline) => pipeline.repoId === selectedRepoId) ?? pipelines[0]; const selectedPipeline = pipelines.find((pipeline) => pipeline.repoId === selectedRepoId) ?? pipelines[0];
const effectiveHistoryTotalPages = historyTotal !== null
? Math.max(1, historyTotalPages ?? Math.ceil(historyTotal / historyPageSize))
: historyTotalPages;
const refreshHistory = useCallback((repoId: string, page: number, silent = false) => { const refreshHistory = useCallback((repoId: string, page: number, silent = false) => {
if (!repoId) { if (!repoId) {
@@ -212,8 +209,8 @@ export function CommonAppBuildPage() {
</div> </div>
<div className="flex items-center justify-between border-t border-border px-5 py-3 text-xs text-muted-foreground"> <div className="flex items-center justify-between border-t border-border px-5 py-3 text-xs text-muted-foreground">
<span> <span>
{historyTotal !== null {historyTotal !== null && historyTotalPages !== null
? `총 ${historyTotal}건 · ${historyPage} / ${effectiveHistoryTotalPages ?? 1}` ? `총 ${historyTotal}건 · ${historyPage} / ${historyTotalPages}`
: `현재 ${historyPage}페이지`} : `현재 ${historyPage}페이지`}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
+16 -109
View File
@@ -1,5 +1,5 @@
import { Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react'; import { Plus, RotateCcw, Server, Settings, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, 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,9 +8,7 @@ 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';
@@ -26,10 +24,6 @@ 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);
} }
@@ -59,17 +53,10 @@ 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 = () => {
@@ -91,25 +78,13 @@ 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) => { .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 });
}) })
.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
@@ -120,27 +95,6 @@ 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);
@@ -192,7 +146,7 @@ export function CommonAppComponentsPage() {
description={component.description} description={component.description}
action={<Badge tone={statusTone(status)}>{status}</Badge>} action={<Badge tone={statusTone(status)}>{status}</Badge>}
/> />
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(220px,0.55fr)_minmax(0,1.45fr)]"> <div className="grid gap-5 p-5 xl:grid-cols-[minmax(280px,0.75fr)_minmax(0,1.25fr)]">
<div className="rounded-md border border-border bg-background p-4"> <div className="rounded-md border border-border bg-background p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground"> <span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
@@ -219,15 +173,7 @@ export function CommonAppComponentsPage() {
</div> </div>
</div> </div>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4">
<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" />
설정값 설정값
@@ -272,59 +218,13 @@ 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="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col" className="!max-w-4xl"
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 ? '저장 중' : '저장'}
@@ -332,8 +232,8 @@ export function CommonAppComponentsPage() {
</> </>
} }
> >
<div className="flex min-h-0 flex-1 flex-col"> <div className="space-y-4">
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border"> <div className="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%]" />
@@ -366,7 +266,6 @@ 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}
@@ -413,6 +312,14 @@ 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>
+8 -64
View File
@@ -24,25 +24,6 @@ function eventTone(type?: string) {
return 'neutral'; return 'neutral';
} }
const eventRanges = [
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
{ label: '전체', value: 'all', milliseconds: null }
];
function eventTime(event: RuntimeEvent) {
const value = event.lastTimestamp ?? event.firstTimestamp;
if (!value) return 0;
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : 0;
}
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
if (!range.milliseconds) return true;
const time = eventTime(event);
return time > 0 && time >= Date.now() - range.milliseconds;
}
function metricKey(namespace: string, pod: string) { function metricKey(namespace: string, pod: string) {
return `${namespace}/${pod}`; return `${namespace}/${pod}`;
} }
@@ -144,7 +125,6 @@ export function CommonAppLogsPage() {
const [tail, setTail] = useState(100); const [tail, setTail] = useState(100);
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs'); const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [eventRange, setEventRange] = useState('1h');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [logLoading, setLogLoading] = useState(false); const [logLoading, setLogLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -162,9 +142,7 @@ export function CommonAppLogsPage() {
const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : []; const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : [];
const loggableResources = componentResources.filter(isActiveLogTarget); const loggableResources = componentResources.filter(isActiveLogTarget);
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined; const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
const filteredEvents = events.filter((event) => { const filteredEvents = events.filter((event) => {
if (!isEventInRange(event, selectedEventRange)) return false;
const normalizedSearch = search.trim().toLowerCase(); const normalizedSearch = search.trim().toLowerCase();
const componentMatch = selectedComponent const componentMatch = selectedComponent
? `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase().includes(selectedComponent.workloadName.toLowerCase()) ? `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase().includes(selectedComponent.workloadName.toLowerCase())
@@ -219,26 +197,6 @@ export function CommonAppLogsPage() {
refresh(); refresh();
}, []); }, []);
useEffect(() => {
if (activeTab !== 'events') return;
let cancelled = false;
const loadEvents = () => {
loadRuntimeEvents(COMMON_APP_NAMESPACE)
.then((nextEvents) => {
if (!cancelled) setEvents(nextEvents);
})
.catch(() => undefined);
};
loadEvents();
const timer = window.setInterval(loadEvents, 10_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [activeTab]);
return ( return (
<> <>
<PageTitle <PageTitle
@@ -371,28 +329,14 @@ export function CommonAppLogsPage() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center"> <div className="relative w-full lg:w-80">
<div className="flex flex-wrap gap-2"> <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
{eventRanges.map((range) => ( <input
<Button className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
key={range.value} placeholder="이벤트 검색"
variant={eventRange === range.value ? 'secondary' : 'outline'} value={search}
className="h-8 px-3 text-[13px]" onChange={(event) => setSearch(event.target.value)}
onClick={() => setEventRange(range.value)} />
>
{range.label}
</Button>
))}
</div>
<div className="relative w-full lg:w-80">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="이벤트 검색"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
</div> </div>
)} )}
</div> </div>
+9 -17
View File
@@ -2,7 +2,6 @@ import { FileText, RotateCcw } 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';
import { CommitAuthor } from '../../components/commit-author';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import type { GiteaRepo } from '../../types/application'; import type { GiteaRepo } from '../../types/application';
@@ -41,6 +40,11 @@ function splitRepository(repository: string) {
return owner && repo ? { owner, repo } : null; return owner && repo ? { owner, repo } : null;
} }
function cicdLabel(pipeline?: CommonAppPipeline) {
if (!pipeline) return '-';
return pipeline.ciUrl ? `Woodpecker / ${pipeline.ciUrl}` : 'Woodpecker';
}
function commitAuthor(commit: GiteaCommit) { function commitAuthor(commit: GiteaCommit) {
return commit.author?.login return commit.author?.login
|| commit.author?.username || commit.author?.username
@@ -278,20 +282,8 @@ export function CommonAppSourcePage() {
<p className="mt-2 truncate text-sm font-semibold">{selectedRepository ? formatRepositoryName(selectedRepository) : '-'}</p> <p className="mt-2 truncate text-sm font-semibold">{selectedRepository ? formatRepositoryName(selectedRepository) : '-'}</p>
</div> </div>
<div className="rounded-md border border-border bg-muted/20 p-4"> <div className="rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3"> <p className="text-xs text-muted-foreground">CI/CD</p>
<p className="text-xs text-muted-foreground">CI/CD</p> <p className="mt-2 truncate text-sm font-semibold">{cicdLabel(selectedPipeline)}</p>
<Badge tone={selectedPipeline ? 'green' : 'neutral'}>{selectedPipeline ? '연결됨' : '미연결'}</Badge>
</div>
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">도구</span>
<span className="font-semibold text-foreground">{selectedPipeline ? 'Woodpecker CI' : '-'}</span>
</div>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">서버</span>
<span className="min-w-0 truncate font-mono text-xs font-medium text-foreground">{selectedPipeline?.ciUrl || '-'}</span>
</div>
</div>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">조회 브랜치</p> <p className="text-xs text-muted-foreground">조회 브랜치</p>
@@ -363,7 +355,7 @@ export function CommonAppSourcePage() {
) : null} ) : null}
{!commitsLoading && !commitsError ? commits.map((commit) => ( {!commitsLoading && !commitsError ? commits.map((commit) => (
<tr key={commit.sha} className="border-b border-border last:border-0"> <tr key={commit.sha} className="border-b border-border last:border-0">
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td> <td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td> <td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td> <td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td> <td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
@@ -378,7 +370,7 @@ export function CommonAppSourcePage() {
</table> </table>
</div> </div>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm"> <div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground">
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`} {totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1,12 +1,12 @@
import { FileCode2, RotateCcw } from 'lucide-react'; import { 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, Modal } from '../../components/ui'; import { Badge, Button, Card, CardHeader } 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, loadRuntimePodMetrics, loadRuntimeResourceYaml, loadRuntimeResources } from '../runtime/runtime-api'; import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, 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', 'PVC', 'PersistentVolume', 'StorageClass'].includes(resource.kind); return ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass'].includes(resource.kind);
} }
function resourceStatusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' { function resourceStatusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
@@ -191,22 +191,6 @@ 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> },
@@ -217,23 +201,7 @@ 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-[220px] px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.detail || '-'}</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: '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>
)
}
], ],
[] []
); );
@@ -241,34 +209,20 @@ function ResourceTable({
const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]); const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]);
return ( return (
<> <DataTableCard
<DataTableCard title={title}
title={title} description={description}
description={description} rows={rows}
rows={rows} columns={columns}
columns={columns} filters={filters}
filters={filters} searchPlaceholder="네임스페이스, 이름, 상태 검색"
searchPlaceholder="네임스페이스, 이름, 상태 검색" getSearchText={(row) => `${row.namespace} ${row.kind} ${row.name} ${row.status} ${row.detail}`}
getSearchText={(row) => `${row.namespace} ${row.kind} ${row.name} ${row.status} ${row.detail}`} getRowKey={(row) => row.id}
getRowKey={(row) => row.id} pageSize={10}
pageSize={10} minWidth={1280}
minWidth={1320} loading={loading}
loading={loading} emptyMessage={emptyMessage}
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>
</>
); );
} }
+37 -58
View File
@@ -30,19 +30,6 @@ type DisplayForm = {
isEnabled: boolean; isEnabled: boolean;
}; };
const iconOptions = [
{ value: 'code', label: 'Code' },
{ value: 'bar-chart', label: 'Bar chart' },
{ value: 'monitor', label: 'Monitor' },
{ value: 'truck', label: 'Truck' },
{ value: 'graduation', label: 'Graduation' },
{ value: 'activity', label: 'Activity' },
{ value: 'sliders', label: 'Sliders' },
{ value: 'shopping-cart', label: 'Shopping cart' },
{ value: 'map', label: 'Map' },
];
const showDevelopmentFields = import.meta.env.DEV;
function releaseLabel(link: LauncherAppLink) { function releaseLabel(link: LauncherAppLink) {
return link.release?.version ?? '-'; return link.release?.version ?? '-';
} }
@@ -314,67 +301,59 @@ export function LauncherAppsPage() {
> >
{displayForm ? ( {displayForm ? (
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}> <form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-[1fr_120px_120px]">
<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">표시명</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={displayForm.displayName} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayName: event.target.value } : prev)} /> <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={displayForm.displayName} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayName: event.target.value } : prev)} />
</label> </label>
<div className="grid gap-4 sm:grid-cols-[1fr_120px]"> <label className="block">
<label className="block"> <span className="text-xs font-medium text-muted-foreground">노출순서</span>
<span className="text-xs font-medium text-muted-foreground">노출순서</span> <input type="number" 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={displayForm.displayOrder} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayOrder: event.target.value } : prev)} />
<input type="number" 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={displayForm.displayOrder} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayOrder: event.target.value } : prev)} /> </label>
<div className="block">
<span className="text-xs font-medium text-muted-foreground">상태</span>
<label 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={displayForm.isEnabled} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, isEnabled: event.target.checked } : prev)} />
활성
</label> </label>
<div className="block">
<span className="text-xs font-medium text-muted-foreground">상태</span>
<label 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={displayForm.isEnabled} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, isEnabled: event.target.checked } : prev)} />
활성
</label>
</div>
</div> </div>
</div> </div>
<label className="block"> <div className="grid gap-4 md:grid-cols-2">
<span className="text-xs font-medium text-muted-foreground">보조 문구</span> <label className="block">
<textarea className="mt-2 min-h-20 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={displayForm.subtitle} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, subtitle: event.target.value } : prev)} /> <span className="text-xs font-medium text-muted-foreground">보조 문구</span>
</label> <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={displayForm.subtitle} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, subtitle: event.target.value } : prev)} />
</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 text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.iconKey} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, iconKey: event.target.value } : prev)} />
</label>
</div>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">설명</span> <span className="text-xs font-medium text-muted-foreground">설명</span>
<textarea className="mt-2 min-h-20 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={displayForm.description} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, description: event.target.value } : prev)} /> <textarea className="mt-2 min-h-20 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={displayForm.description} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, description: event.target.value } : prev)} />
</label> </label>
<label className="block w-full">
<span className="text-xs font-medium text-muted-foreground">자세히 보기 URL 링크</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={displayForm.detailUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, detailUrl: event.target.value } : prev)} />
</label>
{showDevelopmentFields ? (
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">실행 URL</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={displayForm.launchUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, launchUrl: event.target.value } : prev)} />
</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 text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.devAppPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, devAppPath: event.target.value } : prev)} />
</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 text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.execPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, execPath: event.target.value } : prev)} />
</label>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">소개 영상 경로</span> <span className="text-xs font-medium text-muted-foreground">실행 URL</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={displayForm.videoPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, videoPath: event.target.value } : prev)} /> <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={displayForm.launchUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, launchUrl: event.target.value } : prev)} />
</label> </label>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">아이콘 키</span> <span className="text-xs font-medium text-muted-foreground">상세 URL</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={iconOptions.some((option) => option.value === displayForm.iconKey) ? displayForm.iconKey : 'code'} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, iconKey: event.target.value } : prev)}> <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={displayForm.detailUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, detailUrl: event.target.value } : prev)} />
{iconOptions.map((option) => ( </label>
<option key={option.value} value={option.value}>{option.label}</option> <label className="block">
))} <span className="text-xs font-medium text-muted-foreground">개발 앱 경로</span>
</select> <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={displayForm.devAppPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, devAppPath: event.target.value } : prev)} />
</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 text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.execPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, execPath: event.target.value } : prev)} />
</label> </label>
</div> </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 text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.videoPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, videoPath: event.target.value } : prev)} />
</label>
</form> </form>
) : null} ) : null}
</Modal> </Modal>
+4 -132
View File
@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2, Upload } from 'lucide-react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2 } 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,8 +7,6 @@ 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,
@@ -18,7 +16,6 @@ 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';
@@ -51,37 +48,6 @@ function jobTime(job: LauncherBuildJob) {
return Number.isNaN(value) ? 0 : value; return Number.isNaN(value) ? 0 : value;
} }
function BuildWorker({ job }: { job: LauncherBuildJob }) {
const [failed, setFailed] = useState(false);
const name = job.requestedByName
|| job.createdByName
|| job.requestedBy
|| job.createdBy
|| '-';
const avatarUrl = job.requestedByAvatarUrl
|| job.createdByAvatarUrl
|| '';
const initial = name.trim().charAt(0).toUpperCase() || '?';
return (
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle">
{name === '-' ? null : avatarUrl && !failed ? (
<img
src={avatarUrl}
alt=""
className="size-4 shrink-0 rounded-full border border-border bg-muted object-cover"
onError={() => setFailed(true)}
/>
) : (
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[9px] font-semibold leading-none text-muted-foreground">
{initial}
</span>
)}
<b className="truncate leading-none text-foreground">{name}</b>
</span>
);
}
const launcherBuildJobPageSize = 3; const launcherBuildJobPageSize = 3;
type BuildTarget = 'launcher-client' | 'launcher-backend'; type BuildTarget = 'launcher-client' | 'launcher-backend';
type BuildRange = 'week' | 'month' | 'all'; type BuildRange = 'week' | 'month' | 'all';
@@ -135,9 +101,6 @@ 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);
@@ -159,13 +122,6 @@ 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,
@@ -282,31 +238,6 @@ 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
@@ -474,26 +405,12 @@ export function LauncherBuildPage() {
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>브랜치 <b className="text-foreground">{job.branch}</b></span> <span>브랜치 <b className="text-foreground">{job.branch}</b></span>
<span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span> <span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle">작업자 <BuildWorker job={job} /></span>
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span> <span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
{isQuickDeployable(job) ? ( <MoreHorizontal className="size-4" />
<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">
@@ -551,51 +468,6 @@ 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="빌드 실행 확인"
+15 -54
View File
@@ -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, useRef, 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';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
@@ -139,12 +139,9 @@ 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]);
@@ -204,7 +201,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 ?? [])].sort((left, right) => left.key.localeCompare(right.key))); setEnvVariables(result.variables ?? []);
}) })
.catch((error: Error) => { .catch((error: Error) => {
toast({ type: 'error', title: '설정값 조회 실패', message: error.message }); toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
@@ -222,14 +219,6 @@ 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);
@@ -304,7 +293,7 @@ export function LauncherComponentsPage() {
description={component.description} description={component.description}
action={<Badge tone={isClient ? (activeVersion ? 'green' : 'neutral') : statusTone(status)}>{status}</Badge>} action={<Badge tone={isClient ? (activeVersion ? 'green' : 'neutral') : statusTone(status)}>{status}</Badge>}
/> />
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(220px,0.55fr)_minmax(0,1.45fr)]"> <div className="grid gap-5 p-5 xl:grid-cols-[minmax(260px,0.8fr)_minmax(0,1.2fr)]">
<div className="rounded-md border border-border bg-background p-4"> <div className="rounded-md border border-border bg-background p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground"> <span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
@@ -352,7 +341,7 @@ export function LauncherComponentsPage() {
<TerminalSquare className="size-4" /> <TerminalSquare className="size-4" />
로그 로그
</Button> </Button>
<Button onClick={() => setRestartTarget(component)} disabled={!workload || actionKey === component.key}> <Button onClick={() => restart(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>
@@ -424,48 +413,13 @@ 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="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col" className="!max-w-4xl"
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 ? '저장 중' : '저장'}
@@ -473,11 +427,11 @@ export function LauncherComponentsPage() {
</> </>
} }
> >
<div className="flex min-h-0 flex-1 flex-col gap-4"> <div className="space-y-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 ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border"> <div className="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%]" />
@@ -510,7 +464,6 @@ 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}
@@ -557,6 +510,14 @@ 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>
+8 -64
View File
@@ -53,25 +53,6 @@ function eventTone(type?: string) {
return 'neutral'; return 'neutral';
} }
const eventRanges = [
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
{ label: '전체', value: 'all', milliseconds: null }
];
function eventTime(event: RuntimeEvent) {
const value = event.lastTimestamp ?? event.firstTimestamp;
if (!value) return 0;
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : 0;
}
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
if (!range.milliseconds) return true;
const time = eventTime(event);
return time > 0 && time >= Date.now() - range.milliseconds;
}
function metricKey(namespace: string, pod: string) { function metricKey(namespace: string, pod: string) {
return `${namespace}/${pod}`; return `${namespace}/${pod}`;
} }
@@ -174,7 +155,6 @@ export function LauncherLogsPage() {
const [tail, setTail] = useState(100); const [tail, setTail] = useState(100);
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs'); const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [eventRange, setEventRange] = useState('1h');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [logLoading, setLogLoading] = useState(false); const [logLoading, setLogLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -192,9 +172,7 @@ export function LauncherLogsPage() {
const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : []; const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : [];
const loggableResources = componentResources.filter(isActiveLogTarget); const loggableResources = componentResources.filter(isActiveLogTarget);
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined; const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
const filteredEvents = events.filter((event) => { const filteredEvents = events.filter((event) => {
if (!isEventInRange(event, selectedEventRange)) return false;
const normalizedSearch = search.trim().toLowerCase(); const normalizedSearch = search.trim().toLowerCase();
const componentText = `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase(); const componentText = `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase();
const componentMatch = selectedComponent ? componentText.includes(selectedComponent.workloadName.toLowerCase()) : true; const componentMatch = selectedComponent ? componentText.includes(selectedComponent.workloadName.toLowerCase()) : true;
@@ -248,26 +226,6 @@ export function LauncherLogsPage() {
refresh(); refresh();
}, []); }, []);
useEffect(() => {
if (activeTab !== 'events') return;
let cancelled = false;
const loadEvents = () => {
loadRuntimeEvents(LAUNCHER_NAMESPACE)
.then((nextEvents) => {
if (!cancelled) setEvents(nextEvents);
})
.catch(() => undefined);
};
loadEvents();
const timer = window.setInterval(loadEvents, 10_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [activeTab]);
return ( return (
<> <>
<PageTitle <PageTitle
@@ -400,28 +358,14 @@ export function LauncherLogsPage() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center"> <div className="relative w-full lg:w-80">
<div className="flex flex-wrap gap-2"> <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
{eventRanges.map((range) => ( <input
<Button className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
key={range.value} placeholder="이벤트 검색"
variant={eventRange === range.value ? 'secondary' : 'outline'} value={search}
className="h-8 px-3 text-[13px]" onChange={(event) => setSearch(event.target.value)}
onClick={() => setEventRange(range.value)} />
>
{range.label}
</Button>
))}
</div>
<div className="relative w-full lg:w-80">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="이벤트 검색"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
</div> </div>
)} )}
</div> </div>
+2 -3
View File
@@ -2,7 +2,6 @@ import { Copy, FileText, Link2, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useRef, 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 { CommitAuthor } from '../../components/commit-author';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import type { GiteaRepo } from '../../types/application'; import type { GiteaRepo } from '../../types/application';
@@ -689,7 +688,7 @@ export function LauncherSourcePage() {
) : null} ) : null}
{!commitsLoading && !commitsError ? commits.map((commit) => ( {!commitsLoading && !commitsError ? commits.map((commit) => (
<tr key={commit.sha} className="border-b border-border last:border-0"> <tr key={commit.sha} className="border-b border-border last:border-0">
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td> <td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td> <td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td> <td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td> <td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
@@ -710,7 +709,7 @@ export function LauncherSourcePage() {
</table> </table>
</div> </div>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm"> <div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground">
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`} {totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+5 -82
View File
@@ -36,11 +36,6 @@ 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: '',
@@ -133,16 +128,7 @@ export function LauncherVersionsPage() {
const deployBackend = () => { const deployBackend = () => {
if (!latestBackendBuild?.downloadUrl) return; if (!latestBackendBuild?.downloadUrl) return;
setDeploying(true); setDeploying(true);
const requestedReplicas = Number.parseInt(deployReplicas, 10); deployLauncherBackend(latestBackendBuild.downloadUrl)
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: '런처 백엔드를 배포했습니다.' });
@@ -155,15 +141,6 @@ 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
@@ -182,7 +159,7 @@ export function LauncherVersionsPage() {
릴리즈 등록 릴리즈 등록
</Button> </Button>
) : ( ) : (
<Button className="h-8 px-3 text-[13px]" onClick={openBackendDeploy} disabled={!latestBackendBuild?.downloadUrl}> <Button className="h-8 px-3 text-[13px]" onClick={() => setDeployOpen(true)} disabled={!latestBackendBuild?.downloadUrl}>
<Play className="size-4" /> <Play className="size-4" />
백엔드 배포 백엔드 배포
</Button> </Button>
@@ -316,7 +293,7 @@ export function LauncherVersionsPage() {
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p> <p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
</div> </div>
<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">빌드 결과</span>
<select <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" 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={releaseForm.buildJobId} value={releaseForm.buildJobId}
@@ -332,7 +309,7 @@ export function LauncherVersionsPage() {
</label> </label>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div> <div>
<span className="text-xs font-medium text-muted-foreground">버전 <b className="text-foreground">*</b></span> <span className="text-xs font-medium text-muted-foreground">버전</span>
<div className="mt-2 flex h-9 items-center rounded-md border border-border bg-muted/20 px-3 text-sm font-medium text-foreground"> <div className="mt-2 flex h-9 items-center rounded-md border border-border bg-muted/20 px-3 text-sm font-medium text-foreground">
{displayValue(releaseForm.version)} {displayValue(releaseForm.version)}
</div> </div>
@@ -347,7 +324,7 @@ export function LauncherVersionsPage() {
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p> <p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
</div> </div>
<div className="rounded-md border border-border bg-background px-4 py-3"> <div className="rounded-md border border-border bg-background px-4 py-3">
<p className="text-xs text-muted-foreground">다운로드 URL <b className="text-foreground">*</b></p> <p className="text-xs text-muted-foreground">다운로드 URL</p>
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p> <p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p>
</div> </div>
<label className="block"> <label className="block">
@@ -368,60 +345,6 @@ 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>
+2 -22
View File
@@ -29,14 +29,6 @@ export type LauncherBuildJob = {
downloadUrl: string; downloadUrl: string;
artifact: string; artifact: string;
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED'; status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED';
requestedBy?: string | null;
requestedByName?: string | null;
requestedByEmail?: string | null;
requestedByAvatarUrl?: string | null;
createdBy?: string | null;
createdByName?: string | null;
createdByEmail?: string | null;
createdByAvatarUrl?: string | null;
logs?: string | null; logs?: string | null;
startedAt?: string | null; startedAt?: string | null;
finishedAt?: string | null; finishedAt?: string | null;
@@ -63,14 +55,6 @@ 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;
@@ -113,16 +97,12 @@ export type GiteaCommit = {
username?: string; username?: string;
full_name?: string; full_name?: string;
name?: string; name?: string;
avatar_url?: string;
avatarUrl?: string;
} | null; } | null;
committer?: { committer?: {
login?: string; login?: string;
username?: string; username?: string;
full_name?: string; full_name?: string;
name?: string; name?: string;
avatar_url?: string;
avatarUrl?: string;
} | null; } | null;
created?: string; created?: string;
}; };
@@ -289,10 +269,10 @@ export function getLauncherBackendDeployment() {
return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment'); return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment');
} }
export function deployLauncherBackend(image: string, resources?: DeploymentResourceLimits) { export function deployLauncherBackend(image: string) {
return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', { return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', {
method: 'POST', method: 'POST',
body: JSON.stringify({ image, resources: resources && Object.keys(resources).length ? resources : undefined }) body: JSON.stringify({ image })
}); });
} }
+42 -121
View File
@@ -13,7 +13,7 @@ import {
deleteAdminSshKey, deleteAdminSshKey,
loadAdminAppPermissions, loadAdminAppPermissions,
loadAdminRoles, loadAdminRoles,
loadAdminsPage, loadAdmins,
resetAdminPassword, resetAdminPassword,
updateAdminAppPermissions, updateAdminAppPermissions,
updateAdminRole, updateAdminRole,
@@ -44,10 +44,6 @@ const fallbackAdminRoles: AdminRoleOption[] = [
export function AdminManagementPage() { export function AdminManagementPage() {
const { toast } = useToast(); const { toast } = useToast();
const [admins, setAdmins] = useState<AdminAccount[]>([]); const [admins, setAdmins] = useState<AdminAccount[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]); const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -59,9 +55,9 @@ export function AdminManagementPage() {
const [sshTarget, setSshTarget] = useState({ const [sshTarget, setSshTarget] = useState({
admin: null as AdminAccount | null, admin: null as AdminAccount | null,
label: '', label: '',
giteaUsername: '',
publicKey: '' publicKey: ''
}); });
const [sshDeleteTarget, setSshDeleteTarget] = useState<{ admin: AdminAccount; keyId: string } | null>(null);
const [adminForm, setAdminForm] = useState(emptyAdminForm); const [adminForm, setAdminForm] = useState(emptyAdminForm);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle'); const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle'); const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
@@ -79,13 +75,9 @@ export function AdminManagementPage() {
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)])
loadAdminsPage({ page, limit: 6, keyword, role: filterValues.role, state: filterValues.state }), .then(([nextAdmins, nextRoles]) => {
loadAdminRoles().catch(() => fallbackAdminRoles) setAdmins(nextAdmins);
])
.then(([result, nextRoles]) => {
setAdmins(result.items);
setTotal(result.total);
setAdminRoles(nextRoles.filter((role) => role.isActive)); setAdminRoles(nextRoles.filter((role) => role.isActive));
setFailed(false); setFailed(false);
}) })
@@ -99,7 +91,7 @@ export function AdminManagementPage() {
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, [page, keyword, filterValues.role, filterValues.state]); }, []);
useEffect(() => { useEffect(() => {
if (!menuAdmin) return; if (!menuAdmin) return;
@@ -118,7 +110,7 @@ export function AdminManagementPage() {
}, [menuAdmin]); }, [menuAdmin]);
const rows = useMemo<AdminRow[]>( const rows = useMemo<AdminRow[]>(
() => admins.map((admin) => ({ ...admin, no: admin.no ?? 0, state: admin.isActive ? '활성' : '비활성' })), () => admins.map((admin, index) => ({ ...admin, no: admins.length - index, state: admin.isActive ? '활성' : '비활성' })),
[admins] [admins]
); );
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles; const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
@@ -127,7 +119,7 @@ export function AdminManagementPage() {
setMenuAdmin(null); setMenuAdmin(null);
const account = admin.email.split('@')[0] || 'admin'; const account = admin.email.split('@')[0] || 'admin';
const random = Math.random().toString(36).slice(2, 8); const random = Math.random().toString(36).slice(2, 8);
setSshTarget({ admin, label: `${account}-${random}-key`, publicKey: '' }); setSshTarget({ admin, label: `${account}-${random}-key`, giteaUsername: '', publicKey: '' });
setSshState('idle'); setSshState('idle');
setSshError(''); setSshError('');
}; };
@@ -179,13 +171,9 @@ 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()) return;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(adminForm.email.trim())) {
toast({ type: 'error', title: '관리자 등록 실패', message: '올바른 이메일 주소를 입력해주세요.' });
return;
}
setSaveState('saving'); setSaveState('saving');
createAdmin({ ...adminForm, email: adminForm.email.trim().toLowerCase() }) createAdmin(adminForm)
.then(() => { .then(() => {
setSaveState('idle'); setSaveState('idle');
setCreateOpen(false); setCreateOpen(false);
@@ -219,11 +207,12 @@ export function AdminManagementPage() {
setSshError(''); setSshError('');
createAdminSshKey(sshTarget.admin.id, { createAdminSshKey(sshTarget.admin.id, {
label: sshTarget.label.trim() || undefined, label: sshTarget.label.trim() || undefined,
giteaUsername: sshTarget.giteaUsername.trim() || undefined,
publicKey: sshTarget.publicKey.trim() publicKey: sshTarget.publicKey.trim()
}) })
.then(() => { .then(() => {
setSshState('idle'); setSshState('idle');
setSshTarget({ admin: null, label: '', publicKey: '' }); setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' });
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' }); toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
refresh(); refresh();
}) })
@@ -234,12 +223,9 @@ export function AdminManagementPage() {
}); });
}; };
const removeSshKey = () => { const removeSshKey = (admin: AdminAccount, keyId: string) => {
if (!sshDeleteTarget) return; deleteAdminSshKey(admin.id, keyId)
deleteAdminSshKey(sshDeleteTarget.admin.id, sshDeleteTarget.keyId)
.then(() => { .then(() => {
setSshDeleteTarget(null);
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' }); toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
refresh(); refresh();
}) })
@@ -254,12 +240,9 @@ export function AdminManagementPage() {
header: '관리자', header: '관리자',
className: 'w-[260px] px-5 py-3 font-medium', className: 'w-[260px] px-5 py-3 font-medium',
cell: (row) => ( cell: (row) => (
<div className="flex min-w-0 items-center gap-3"> <div>
<AdminAvatar admin={row} /> <p className="font-semibold">{row.name}</p>
<div className="min-w-0"> <p className="mt-1 text-xs text-muted-foreground">{row.email}</p>
<p className="truncate font-semibold">{row.name}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{row.email}</p>
</div>
</div> </div>
) )
}, },
@@ -295,7 +278,7 @@ export function AdminManagementPage() {
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring" className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
onClick={() => openSshModal(row)} onClick={() => openSshModal(row)}
> >
<StatusBadge status={keyCount > 0 ? '등록' : '미등록'} tone={keyCount > 0 ? 'green' : 'neutral'} /> <StatusBadge status={`${keyCount}개`} tone={keyCount > 0 ? 'green' : 'neutral'} />
</button> </button>
); );
} }
@@ -330,10 +313,10 @@ export function AdminManagementPage() {
const filters = useMemo<Array<DataTableFilter<AdminRow>>>( const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
() => [ () => [
{ key: 'role', label: '권한', options: roleOptions.map((role) => ({ label: role.name, value: role.code })), getValue: (row) => row.role }, { key: 'role', label: '권한', options: [...new Set(rows.map((row) => row.role))].map((role) => ({ label: role, value: role })), getValue: (row) => row.role },
{ key: 'state', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '비활성', value: 'inactive' }], getValue: (row) => row.isActive ? 'active' : 'inactive' } { key: 'state', label: '상태', options: [...new Set(rows.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
], ],
[roleOptions] [rows]
); );
return ( return (
@@ -366,46 +349,19 @@ export function AdminManagementPage() {
minWidth={1040} minWidth={1040}
loading={loading} loading={loading}
emptyMessage="등록된 관리자가 없습니다." emptyMessage="등록된 관리자가 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
{menuAdmin ? (() => { {menuAdmin ? (() => {
const row = rows.find((item) => item.id === menuAdmin.id); const row = rows.find((item) => item.id === menuAdmin.id);
if (!row) return null; if (!row) return null;
return ( return (
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}> <div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}>
{row.sshKeys?.length ? ( <button
<button type="button"
type="button" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-muted" onClick={() => openSshModal(row)}
onClick={() => { >
setMenuAdmin(null); SSH 키 등록
setSshDeleteTarget({ admin: row, keyId: row.sshKeys![0].id }); </button>
}}
>
SSH 키 삭제
</button>
) : (
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
onClick={() => openSshModal(row)}
>
SSH 키 등록
</button>
)}
<button <button
type="button" type="button"
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
@@ -440,26 +396,26 @@ export function AdminManagementPage() {
<Modal <Modal
open={createOpen} open={createOpen}
title="관리자 추가" title="관리자 등록"
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
footer={ footer={
<> <>
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button> <Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
<Button onClick={submitAdmin} disabled={saveState === 'saving' || !adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8}>{saveState === 'saving' ? '저장 중' : '등록'}</Button> <Button onClick={submitAdmin} disabled={saveState === 'saving'}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
</> </>
} }
> >
<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">이메일</span>
<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 }))} /> <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 }))} />
</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">이름</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.name} onChange={(event) => setAdminForm((prev) => ({ ...prev, name: event.target.value }))} /> <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.name} onChange={(event) => setAdminForm((prev) => ({ ...prev, name: 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">권한</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={adminForm.role} onChange={(event) => setAdminForm((prev) => ({ ...prev, role: event.target.value }))}> <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={adminForm.role} onChange={(event) => setAdminForm((prev) => ({ ...prev, role: event.target.value }))}>
{roleOptions.map((role) => ( {roleOptions.map((role) => (
<option key={role.code} value={role.code}> <option key={role.code} value={role.code}>
@@ -469,7 +425,7 @@ export function AdminManagementPage() {
</select> </select>
</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">임시 비밀번호</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.temporaryPassword} onChange={(event) => setAdminForm((prev) => ({ ...prev, temporaryPassword: event.target.value }))} /> <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.temporaryPassword} onChange={(event) => setAdminForm((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
</label> </label>
</div> </div>
@@ -495,11 +451,11 @@ export function AdminManagementPage() {
<Modal <Modal
open={Boolean(sshTarget.admin)} open={Boolean(sshTarget.admin)}
title="SSH 키 등록" title="SSH 키 등록"
onClose={() => setSshTarget({ admin: null, label: '', publicKey: '' })} onClose={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}
className="max-w-2xl" className="max-w-2xl"
footer={ footer={
<> <>
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', publicKey: '' })}>취소</Button> <Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>취소</Button>
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button> <Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
</> </>
} }
@@ -509,9 +465,10 @@ export function AdminManagementPage() {
<span className="text-xs font-medium text-muted-foreground">키 이름</span> <span className="text-xs font-medium text-muted-foreground">키 이름</span>
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} /> <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={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
</label> </label>
<p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"> <label className="block">
저장소 사용자명은 관리자 이메일 기준으로 자동 적용됩니다. <span className="text-xs font-medium text-muted-foreground">저장소 사용자명</span>
</p> <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={sshTarget.giteaUsername} onChange={(event) => setSshTarget((prev) => ({ ...prev, giteaUsername: event.target.value }))} />
</label>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">공개키</span> <span className="text-xs font-medium text-muted-foreground">공개키</span>
<textarea className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} /> <textarea className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
@@ -525,7 +482,7 @@ export function AdminManagementPage() {
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p> <p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p> <p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
</div> </div>
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && setSshDeleteTarget({ admin: sshTarget.admin, keyId: key.id })}> <Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && removeSshKey(sshTarget.admin, key.id)}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
</div> </div>
@@ -535,20 +492,6 @@ export function AdminManagementPage() {
</div> </div>
</Modal> </Modal>
<Modal
open={Boolean(sshDeleteTarget)}
title="SSH 키 삭제"
onClose={() => setSshDeleteTarget(null)}
footer={
<>
<Button variant="outline" onClick={() => setSshDeleteTarget(null)}>취소</Button>
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={removeSshKey}>삭제</Button>
</>
}
>
<p className="text-sm text-muted-foreground">등록된 관리자 SSH 키를 삭제합니다. Gitea에 등록된 키도 함께 삭제됩니다.</p>
</Modal>
<Modal <Modal
open={Boolean(permissionTarget.admin)} open={Boolean(permissionTarget.admin)}
title="앱 권한" title="앱 권한"
@@ -604,25 +547,3 @@ export function AdminManagementPage() {
</> </>
); );
} }
function AdminAvatar({ admin }: { admin: AdminAccount }) {
const [failed, setFailed] = useState(false);
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
if (admin.avatarUrl && !failed) {
return (
<img
src={admin.avatarUrl}
alt=""
className="size-9 shrink-0 rounded-full border border-border bg-muted object-cover"
onError={() => setFailed(true)}
/>
);
}
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-sm font-semibold text-muted-foreground">
{initial}
</span>
);
}
+50 -272
View File
@@ -8,23 +8,31 @@ import { formatDateOnly } from '../../lib/date-format';
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils'; import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
import { import {
assignUserLicense, assignUserLicense,
loadLicenseAppPermissions,
loadLicenseAssignments,
loadLicensePlans, loadLicensePlans,
loadLicenseUsers, loadLicenseUsers,
loadLauncherUserAppPermissions, loadLauncherUserAppPermissions,
revokeUserLicense, revokeUserLicense,
updateLicensePlan,
updateLauncherUserAppPermissions, updateLauncherUserAppPermissions,
updateUserLicense, updateUserLicense,
type AuthLicenseUser, type AuthLicenseUser,
type LauncherUserAppPermission, type LauncherUserAppPermission,
type LicenseAppPermission,
type LicenseAssignment,
type LicensePlan type LicensePlan
} from './operations-api'; } from './operations-api';
type LicenseAssignmentRow = LicenseAssignment; type LicenseAssignmentRow = {
id: string;
no: number;
userId: string;
user: string;
name: string;
role: string;
code: string;
licenseName: string;
startsAt: string;
expiresAt?: string | null;
assignedBy?: string | null;
status: string;
};
function today() { function today() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
@@ -51,15 +59,28 @@ function toDateInputValue(value?: string | null) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function assignmentRows(users: AuthLicenseUser[]) {
return users.flatMap((user) =>
user.licenses.map((license) => ({
id: license.id,
userId: user.id,
user: user.email,
name: user.name,
role: user.role,
code: license.license.code,
licenseName: license.license.name,
startsAt: license.startsAt,
expiresAt: license.expiresAt,
assignedBy: license.assignedById,
status: license.status
}))
);
}
export function LicensesPage() { export function LicensesPage() {
const { toast } = useToast(); const { toast } = useToast();
const [plans, setPlans] = useState<LicensePlan[]>([]); const [plans, setPlans] = useState<LicensePlan[]>([]);
const [users, setUsers] = useState<AuthLicenseUser[]>([]); const [users, setUsers] = useState<AuthLicenseUser[]>([]);
const [rows, setRows] = useState<LicenseAssignmentRow[]>([]);
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 [loading, setLoading] = useState(true);
const [assignOpen, setAssignOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false);
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null); const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
@@ -67,18 +88,8 @@ export function LicensesPage() {
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null); const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]); const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
const [appPermissionLoading, setAppPermissionLoading] = useState(false); const [appPermissionLoading, setAppPermissionLoading] = useState(false);
const [planEditTarget, setPlanEditTarget] = useState<LicensePlan | null>(null);
const [planStatusTarget, setPlanStatusTarget] = useState<LicensePlan | null>(null);
const [licenseAppPermissions, setLicenseAppPermissions] = useState<LicenseAppPermission[]>([]);
const [licenseAppLoading, setLicenseAppLoading] = useState(false);
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null); const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
const [planMenu, setPlanMenu] = useState<{ code: string; x: number; y: number } | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [planForm, setPlanForm] = useState({
name: '',
description: '',
defaultDurationDays: ''
});
const [assignForm, setAssignForm] = useState({ const [assignForm, setAssignForm] = useState({
userId: '', userId: '',
licenseCode: '', licenseCode: '',
@@ -88,6 +99,10 @@ export function LicensesPage() {
const [userKeyword, setUserKeyword] = useState(''); const [userKeyword, setUserKeyword] = useState('');
const loadingVisible = useMinimumLoading(loading); const loadingVisible = useMinimumLoading(loading);
const rows = useMemo(() => {
const assignments = assignmentRows(users);
return assignments.map((assignment, index) => ({ ...assignment, no: assignments.length - index }));
}, [users]);
const calculateExpiresAt = (licenseCode: string, startsAt: string) => { const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
const plan = plans.find((item) => item.code === licenseCode); const plan = plans.find((item) => item.code === licenseCode);
return addDays(startsAt, plan?.defaultDurationDays); return addDays(startsAt, plan?.defaultDurationDays);
@@ -105,17 +120,8 @@ export function LicensesPage() {
setLoading(true); setLoading(true);
try { try {
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]); const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
const nextAssignments = await loadLicenseAssignments({
page,
limit: 6,
keyword,
licenseCode: filterValues.licenseName,
status: filterValues.status,
});
setPlans(nextPlans); setPlans(nextPlans);
setUsers(nextUsers); setUsers(nextUsers);
setRows(nextAssignments.items);
setTotal(nextAssignments.total);
setAssignForm((prev) => ({ setAssignForm((prev) => ({
...prev, ...prev,
licenseCode: prev.licenseCode || nextPlans[0]?.code || '', licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
@@ -130,17 +136,14 @@ export function LicensesPage() {
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
}, [page, keyword, filterValues.licenseName, filterValues.status]); }, []);
useEffect(() => { useEffect(() => {
if (!menuRow && !planMenu) return; if (!menuRow) return;
const close = () => { const close = () => setMenuRow(null);
setMenuRow(null);
setPlanMenu(null);
};
const closeOnEscape = (event: KeyboardEvent) => { const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') close(); if (event.key === 'Escape') setMenuRow(null);
}; };
window.addEventListener('click', close); window.addEventListener('click', close);
@@ -149,7 +152,7 @@ export function LicensesPage() {
window.removeEventListener('click', close); window.removeEventListener('click', close);
window.removeEventListener('keydown', closeOnEscape); window.removeEventListener('keydown', closeOnEscape);
}; };
}, [menuRow, planMenu]); }, [menuRow]);
const openAssignModal = () => { const openAssignModal = () => {
const startsAt = today(); const startsAt = today();
@@ -277,73 +280,6 @@ export function LicensesPage() {
} }
}; };
const openPlanEditModal = async (plan: LicensePlan) => {
setPlanMenu(null);
setPlanEditTarget(plan);
setPlanForm({
name: plan.name,
description: plan.description ?? '',
defaultDurationDays: plan.defaultDurationDays ? String(plan.defaultDurationDays) : ''
});
setLicenseAppPermissions([]);
setLicenseAppLoading(true);
try {
setLicenseAppPermissions(await loadLicenseAppPermissions(plan.code));
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 앱 설정을 불러오지 못했습니다.' });
} finally {
setLicenseAppLoading(false);
}
};
const closePlanEditModal = () => {
setPlanEditTarget(null);
setLicenseAppPermissions([]);
};
const savePlanEdit = async () => {
if (!planEditTarget || !planForm.name.trim()) return;
setSaving(true);
try {
const result = await updateLicensePlan(planEditTarget.code, {
name: planForm.name.trim(),
description: planForm.description.trim() || null,
defaultDurationDays: planForm.defaultDurationDays ? Number(planForm.defaultDurationDays) : null,
permissions: licenseAppPermissions.map((permission) => ({
appKey: permission.appKey,
canLaunch: permission.canLaunch
}))
});
toast({ type: 'success', message: `라이센스를 수정했습니다. ${result.updatedUserCount ?? 0}명에게 반영했습니다.` });
closePlanEditModal();
await refresh();
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 수정에 실패했습니다.' });
} finally {
setSaving(false);
}
};
const togglePlanActive = async (plan: LicensePlan) => {
setSaving(true);
try {
await updateLicensePlan(plan.code, { isActive: !plan.isActive });
toast({ type: 'success', message: plan.isActive ? '라이센스를 비활성화했습니다.' : '라이센스를 활성화했습니다.' });
setPlanStatusTarget(null);
await refresh();
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 상태 변경에 실패했습니다.' });
} finally {
setSaving(false);
}
};
const toggleLicenseAppLaunch = (appKey: string) => {
setLicenseAppPermissions((prev) => prev.map((permission) =>
permission.appKey === appKey ? { ...permission, canLaunch: !permission.canLaunch, canView: true } : permission
));
};
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>( const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
() => [ () => [
{ 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: '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> },
@@ -391,10 +327,10 @@ export function LicensesPage() {
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>( const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
() => [ () => [
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.code })), getValue: (row) => row.code }, { key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName },
{ key: 'status', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '만료', value: 'expired' }], getValue: (row) => row.status === '활성' ? 'active' : 'expired' } { key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status }
], ],
[plans] [plans, rows]
); );
return ( return (
@@ -406,7 +342,7 @@ export function LicensesPage() {
actions={ actions={
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}> <Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
<Plus className="size-4" /> <Plus className="size-4" />
등록 라이센스 부여
</Button> </Button>
} }
/> />
@@ -418,22 +354,7 @@ export function LicensesPage() {
<p className="text-sm text-foreground">{plan.name}</p> <p className="text-sm text-foreground">{plan.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p> <p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <StatusBadge status={plan.isActive ? '활성' : '비활성'} />
{!plan.isActive ? <StatusBadge status="비활성" /> : null}
<Button
variant="ghost"
className="size-8 px-0"
title="메뉴"
aria-label="메뉴"
onClick={(event) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
setPlanMenu((prev) => (prev?.code === plan.code ? null : { code: plan.code, x: rect.right, y: rect.bottom + 6 }));
}}
>
<MoreHorizontal className="size-4" />
</Button>
</div>
</div> </div>
<div className="mt-5 grid grid-cols-3 gap-3"> <div className="mt-5 grid grid-cols-3 gap-3">
<div> <div>
@@ -458,31 +379,6 @@ export function LicensesPage() {
</Card> </Card>
) : null} ) : null}
</div> </div>
{planMenu ? (() => {
const plan = plans.find((item) => item.code === planMenu.code);
if (!plan) 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: planMenu.x, top: planMenu.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={() => void openPlanEditModal(plan)}
>
수정
</button>
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
onClick={() => {
setPlanMenu(null);
setPlanStatusTarget(plan);
}}
>
{plan.isActive ? '비활성' : '활성'}
</button>
</div>
);
})() : null}
<div className="mt-4"> <div className="mt-4">
<DataTableCard <DataTableCard
title="목록" title="목록"
@@ -496,20 +392,6 @@ export function LicensesPage() {
minWidth={980} minWidth={980}
loading={loading} loading={loading}
emptyMessage="부여된 라이센스가 없습니다." emptyMessage="부여된 라이센스가 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
</div> </div>
@@ -551,7 +433,7 @@ export function LicensesPage() {
title={ title={
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<Pencil className="size-4 text-primary" /> <Pencil className="size-4 text-primary" />
{editingRow ? '라이센스 수정' : '라이센스 추가'} {editingRow ? '라이센스 수정' : '라이센스 부여'}
</span> </span>
} }
onClose={closeAssignModal} onClose={closeAssignModal}
@@ -567,7 +449,7 @@ export function LicensesPage() {
<div className="space-y-4"> <div className="space-y-4">
{editingRow ? ( {editingRow ? (
<div> <div>
<span className="text-xs font-medium text-muted-foreground">사용자 <b className="text-foreground">*</b></span> <span className="text-xs font-medium text-muted-foreground">사용자</span>
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm"> <div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
<p className="text-foreground">{editingRow.user}</p> <p className="text-foreground">{editingRow.user}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p> <p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
@@ -623,7 +505,7 @@ export function LicensesPage() {
</div> </div>
)} )}
<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">라이센스</span>
<select <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" 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={assignForm.licenseCode} value={assignForm.licenseCode}
@@ -641,7 +523,7 @@ export function LicensesPage() {
</label> </label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<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">시작일</span>
<input <input
type="date" type="date"
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" 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"
@@ -668,110 +550,6 @@ export function LicensesPage() {
</div> </div>
</div> </div>
</Modal> </Modal>
<Modal
open={Boolean(planEditTarget)}
title={
<span className="inline-flex items-center gap-2">
<Pencil className="size-4 text-primary" />
라이센스 수정
</span>
}
onClose={closePlanEditModal}
className="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={closePlanEditModal} disabled={saving}>취소</Button>
<Button onClick={savePlanEdit} disabled={saving || licenseAppLoading || !planForm.name.trim()}>
{saving ? '저장 중' : '저장'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<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 placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={planForm.name}
onChange={(event) => setPlanForm((prev) => ({ ...prev, name: event.target.value }))}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">기본 기간</span>
<input
type="number"
min={1}
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={planForm.defaultDurationDays}
onChange={(event) => setPlanForm((prev) => ({ ...prev, defaultDurationDays: event.target.value }))}
/>
</label>
</div>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">설명</span>
<textarea
className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={planForm.description}
onChange={(event) => setPlanForm((prev) => ({ ...prev, description: event.target.value }))}
/>
</label>
<div>
<span className="text-xs font-medium text-muted-foreground">앱 실행 설정</span>
<div className="mt-2 max-h-64 overflow-y-auto rounded-md border border-border">
{licenseAppLoading ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">앱 설정을 불러오는 중입니다.</div>
) : licenseAppPermissions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">런처에 연결된 앱이 없습니다.</div>
) : (
licenseAppPermissions.map((permission) => (
<div key={permission.appKey} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
<div className="min-w-0">
<p className="truncate text-sm text-foreground">{permission.displayName}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{permission.appKey}</p>
</div>
<label className="inline-flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
className="size-4 accent-primary"
checked={permission.canLaunch}
onChange={() => toggleLicenseAppLaunch(permission.appKey)}
/>
실행
</label>
</div>
))
)}
</div>
</div>
</div>
</Modal>
<Modal
open={Boolean(planStatusTarget)}
title={
<span className="inline-flex items-center gap-2">
<ShieldCheck className="size-4 text-primary" />
라이센스 상태 변경
</span>
}
onClose={() => setPlanStatusTarget(null)}
footer={
<>
<Button variant="outline" onClick={() => setPlanStatusTarget(null)} disabled={saving}>취소</Button>
<Button
className={planStatusTarget?.isActive ? 'border border-amber-500/40 bg-amber-500/15 text-amber-100 hover:bg-amber-500/25' : undefined}
onClick={() => planStatusTarget ? void togglePlanActive(planStatusTarget) : undefined}
disabled={saving}
>
{saving ? '처리 중' : planStatusTarget?.isActive ? '비활성' : '활성'}
</Button>
</>
}
>
<p className="pb-6 text-sm text-foreground">
{planStatusTarget?.name ?? '라이센스'}를 {planStatusTarget?.isActive ? '비활성화' : '활성화'}할까요?
</p>
</Modal>
<Modal <Modal
open={Boolean(deleteTarget)} open={Boolean(deleteTarget)}
title={ title={
-146
View File
@@ -1,146 +0,0 @@
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} />
</>;
}
+13 -65
View File
@@ -9,7 +9,7 @@ import { listApps } from '../applications/application-api';
import { import {
createLauncherNotice, createLauncherNotice,
deleteLauncherNotice, deleteLauncherNotice,
loadLauncherNoticesPage, loadLauncherNotices,
updateLauncherNotice, updateLauncherNotice,
type LauncherNotice type LauncherNotice
} from './operations-api'; } from './operations-api';
@@ -73,34 +73,6 @@ function exposureCell(row: LauncherNotice) {
); );
} }
function NoticeCreator({ notice }: { notice: LauncherNotice }) {
const [failed, setFailed] = useState(false);
const name = notice.createdByName || notice.createdByEmail || 'admin';
const avatarUrl = notice.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">{formatNoticeDateTime(notice.createdAt)}</p>
</div>
</div>
);
}
function toForm(notice: LauncherNotice): NoticeForm { function toForm(notice: LauncherNotice): NoticeForm {
return { return {
appKey: notice.appKey, appKey: notice.appKey,
@@ -123,10 +95,6 @@ export function NoticesPage() {
const { toast } = useToast(); const { toast } = useToast();
const [notices, setNotices] = useState<LauncherNotice[]>([]); const [notices, setNotices] = useState<LauncherNotice[]>([]);
const [apps, setApps] = useState<AppWithComponents[]>([]); const [apps, setApps] = useState<AppWithComponents[]>([]);
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 [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -140,12 +108,11 @@ export function NoticesPage() {
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
loadLauncherNoticesPage({ page, limit: 6, keyword, appKey: filterValues.appKey }), loadLauncherNotices(),
listApps().catch(() => []) listApps().catch(() => [])
]) ])
.then(([result, appItems]) => { .then(([items, appItems]) => {
setNotices(result.items); setNotices(items);
setTotal(result.total);
setApps(appItems); setApps(appItems);
setFailed(false); setFailed(false);
}) })
@@ -159,7 +126,7 @@ export function NoticesPage() {
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, [page, keyword, filterValues.appKey]); }, []);
useEffect(() => { useEffect(() => {
if (!menuNotice) return; if (!menuNotice) return;
@@ -189,7 +156,7 @@ export function NoticesPage() {
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey; const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
const rows = useMemo( const rows = useMemo(
() => notices.map((notice) => ({ ...notice, no: notice.no ?? 0, appName: appLabel(notice.appKey) })), () => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })),
[appNameByKey, notices] [appNameByKey, notices]
); );
@@ -279,7 +246,7 @@ export function NoticesPage() {
}, },
{ key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> }, { key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> },
{ key: 'exposure', header: '노출 일시', className: 'w-48 px-5 py-3 font-medium', cell: exposureCell }, { key: 'exposure', header: '노출 일시', className: 'w-48 px-5 py-3 font-medium', cell: exposureCell },
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <NoticeCreator notice={row} /> }, { key: 'createdAt', header: '생성일시', cell: (row) => <span className="text-muted-foreground">{formatNoticeDateTime(row.createdAt)}</span> },
{ {
key: 'actions', key: 'actions',
header: '', header: '',
@@ -308,14 +275,9 @@ export function NoticesPage() {
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>( const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
() => [ () => [
{ { key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey }
key: 'appKey',
label: '대상 앱',
options: ['Launcher', ...apps.map((app) => app.key)].map((appKey) => ({ label: appLabel(appKey), value: appKey })),
getValue: (row) => row.appKey
}
], ],
[apps, appNameByKey] [rows]
); );
return ( return (
@@ -348,20 +310,6 @@ export function NoticesPage() {
minWidth={1040} minWidth={1040}
loading={loading} loading={loading}
emptyMessage="등록된 공지사항이 없습니다." emptyMessage="등록된 공지사항이 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
{menuNotice ? (() => { {menuNotice ? (() => {
@@ -392,7 +340,7 @@ export function NoticesPage() {
title={ title={
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<Pencil className="size-4 text-primary" /> <Pencil className="size-4 text-primary" />
{editing ? '공지사항 수정' : '공지사항 추가'} 공지사항
</span> </span>
} }
onClose={closeModal} onClose={closeModal}
@@ -409,7 +357,7 @@ export function NoticesPage() {
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}> <form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
<div className="grid gap-4 md:grid-cols-[1fr_120px]"> <div className="grid gap-4 md:grid-cols-[1fr_120px]">
<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">대상 앱</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.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}> <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.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
<option value="Launcher">Launcher</option> <option value="Launcher">Launcher</option>
{apps.map((app) => ( {apps.map((app) => (
@@ -426,11 +374,11 @@ export function NoticesPage() {
</label> </label>
</div> </div>
<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">제목</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 }))} /> <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>
<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">본문</span>
<textarea className="mt-2 min-h-24 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 }))} /> <textarea className="mt-2 min-h-24 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> </label>
<label className="block"> <label className="block">
+13 -127
View File
@@ -8,11 +8,8 @@ import { formatDateTime } from '../../lib/date-format';
import { import {
createOrganization, createOrganization,
deleteOrganization, deleteOrganization,
loadLicensePlans, loadOrganizations,
loadOrganizationsPage,
updateOrganization, updateOrganization,
updateOrganizationLicense,
type LicensePlan,
type Organization, type Organization,
type OrganizationType type OrganizationType
} from './operations-api'; } from './operations-api';
@@ -36,41 +33,14 @@ 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[]>([]);
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
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 [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Organization | null>(null); const [editing, setEditing] = useState<Organization | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null); const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
const [licenseTarget, setLicenseTarget] = useState<{ organization: Organization; licenseCode: string } | null>(null);
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null); const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
@@ -78,10 +48,9 @@ export function OrganizationsPage() {
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
loadOrganizationsPage({ page, limit: 10, keyword, type: filterValues.type }) loadOrganizations()
.then((result) => { .then((items) => {
setOrganizations(result.items); setOrganizations(items);
setTotal(result.total);
setFailed(false); setFailed(false);
}) })
.catch((error: Error) => { .catch((error: Error) => {
@@ -94,10 +63,7 @@ export function OrganizationsPage() {
useEffect(() => { useEffect(() => {
refresh(); refresh();
loadLicensePlans() }, []);
.then(setLicensePlans)
.catch(() => setLicensePlans([]));
}, [page, keyword, filterValues.type]);
useEffect(() => { useEffect(() => {
if (!menuOrganization) return; if (!menuOrganization) return;
@@ -116,7 +82,7 @@ export function OrganizationsPage() {
}, [menuOrganization]); }, [menuOrganization]);
const rows = useMemo( const rows = useMemo(
() => organizations.map((organization) => ({ ...organization, no: organization.no ?? 0 })), () => organizations.map((organization, index) => ({ ...organization, no: organizations.length - index })),
[organizations] [organizations]
); );
@@ -135,10 +101,8 @@ 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: '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-48 px-5 py-3', cell: (row) => <OrganizationCreator organization={row} /> }, { key: 'createdAt', header: '등록일', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.createdAt)}</span> },
{ {
key: 'actions', key: 'actions',
header: '', header: '',
@@ -256,7 +220,7 @@ export function OrganizationsPage() {
actions={ actions={
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}> <Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
<Plus className="size-4" /> <Plus className="size-4" />
추가 조직 등록
</Button> </Button>
} }
/> />
@@ -277,20 +241,6 @@ export function OrganizationsPage() {
minWidth={980} minWidth={980}
loading={loading} loading={loading}
emptyMessage="표시할 조직 데이터가 없습니다." emptyMessage="표시할 조직 데이터가 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
{menuOrganization ? (() => { {menuOrganization ? (() => {
const row = rows.find((item) => item.id === menuOrganization.id); const row = rows.find((item) => item.id === menuOrganization.id);
@@ -298,16 +248,6 @@ export function OrganizationsPage() {
return ( return (
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.y, transform: 'translateX(-100%)' }}> <div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.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={() => {
setMenuOrganization(null);
setLicenseTarget({ organization: row, licenseCode: row.defaultLicenseCode ?? '' });
}}
>
라이센스 설정
</button>
<button <button
type="button" type="button"
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
@@ -331,65 +271,9 @@ export function OrganizationsPage() {
</div> </div>
); );
})() : null} })() : null}
<Modal
open={Boolean(licenseTarget)}
title="조직 라이센스 설정"
onClose={() => setLicenseTarget(null)}
footer={
<>
<Button variant="outline" onClick={() => setLicenseTarget(null)} disabled={saving}>취소</Button>
<Button
disabled={saving || !licenseTarget}
onClick={() => {
if (!licenseTarget) return;
setSaving(true);
updateOrganizationLicense(licenseTarget.organization.id, licenseTarget.licenseCode || null)
.then((result) => {
toast({
type: 'success',
title: '라이센스 설정 완료',
message: `${result.updatedUserCount}명의 사용자 라이센스를 갱신했습니다.`
});
setLicenseTarget(null);
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '라이센스 설정 실패', message: error.message });
})
.finally(() => setSaving(false));
}}
>
{saving ? '저장 중' : '저장'}
</Button>
</>
}
>
{licenseTarget ? (
<div className="space-y-4">
<div className="rounded-md border border-border bg-background/70 px-3 py-2 text-sm">
<p className="font-semibold text-foreground">{licenseTarget.organization.name}</p>
<p className="mt-1 text-xs text-muted-foreground">저장하면 해당 조직에 소속된 사용자의 현재 라이센스가 선택한 라이센스로 갱신됩니다.</p>
</div>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={licenseTarget.licenseCode}
onChange={(event) => setLicenseTarget((prev) => prev ? { ...prev, licenseCode: event.target.value } : prev)}
>
<option value="">없음</option>
{licensePlans.map((plan) => (
<option key={plan.code} value={plan.code}>{plan.name}</option>
))}
</select>
</label>
</div>
) : null}
</Modal>
<Modal <Modal
open={modalOpen} open={modalOpen}
title={editing ? '조직 수정' : '조직 추가'} title={editing ? '조직 수정' : '조직 등록'}
onClose={closeModal} onClose={closeModal}
footer={ footer={
<> <>
@@ -400,15 +284,16 @@ export function OrganizationsPage() {
> >
<div className="space-y-4"> <div className="space-y-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">조직명</span>
<input <input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={form.name} value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="예: 영남대학교"
/> />
</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">유형</span>
<select <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" 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.type} value={form.type}
@@ -425,6 +310,7 @@ export function OrganizationsPage() {
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={form.domain} value={form.domain}
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))} onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
placeholder="yu.ac.kr"
/> />
</label> </label>
</div> </div>
-379
View File
@@ -1,379 +0,0 @@
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} />
</>
);
}
+14 -238
View File
@@ -1,60 +1,25 @@
import { MoreHorizontal, Plus } from 'lucide-react'; import { MoreHorizontal } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table'; import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
import { Button, Modal } from '../../components/ui'; import { Button, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
createManagedUser,
loadLicensePlans,
loadOrganizations,
loadUserSessionsPage,
lockUser,
terminateUserSession,
unlockUser,
updateLmsInstructor,
type LicensePlan,
type Organization,
type UserSession
} from './operations-api';
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils'; import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
const emptyCreateForm = {
email: '',
name: '',
password: '',
organizationId: '',
department: '',
position: '',
licenseCode: 'EDU' as 'FREE' | 'EDU' | 'PRO'
};
export function UserSessionsPage() { export function UserSessionsPage() {
const { toast } = useToast();
const [sessions, setSessions] = useState<UserSession[]>([]); const [sessions, setSessions] = useState<UserSession[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState(emptyCreateForm);
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 = () => {
setLoading(true); setLoading(true);
loadUserSessionsPage({ page, limit: 6, keyword, state: filterValues.state }) loadUserSessions()
.then((result) => { .then((nextSessions) => {
setSessions(result.items); setSessions(nextSessions);
setTotal(result.total);
setFailed(false); setFailed(false);
}) })
.catch(() => { .catch(() => {
@@ -66,19 +31,7 @@ export function UserSessionsPage() {
useEffect(() => { useEffect(() => {
refresh(); refresh();
Promise.all([ }, []);
loadOrganizations().catch(() => []),
loadLicensePlans().catch(() => [])
]).then(([nextOrganizations, nextLicensePlans]) => {
setOrganizations(nextOrganizations);
setLicensePlans(nextLicensePlans);
setCreateForm((current) => ({
...current,
organizationId: current.organizationId || '',
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
}));
});
}, [page, keyword, filterValues.state]);
useEffect(() => { useEffect(() => {
if (!menuSession) return; if (!menuSession) return;
@@ -96,7 +49,7 @@ export function UserSessionsPage() {
}; };
}, [menuSession]); }, [menuSession]);
const rows = sessions; const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]);
const columns = useMemo<Array<DataTableColumn<UserSession>>>( const columns = useMemo<Array<DataTableColumn<UserSession>>>(
() => [ () => [
@@ -118,7 +71,7 @@ export function UserSessionsPage() {
{ key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> }, { key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
{ key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.expiresAt)}</span> }, { key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
{ key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.lastSeen)}</span> }, { key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
{ key: 'state', header: '상태', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={row.state} /> }, { key: 'state', header: '상태', className: 'w-24 px-5 py-3', cell: (row) => <StatusBadge status={row.state} /> },
{ {
key: 'actions', key: 'actions',
header: '', header: '',
@@ -147,78 +100,17 @@ export function UserSessionsPage() {
const filters = useMemo<Array<DataTableFilter<UserSession>>>( const filters = useMemo<Array<DataTableFilter<UserSession>>>(
() => [ () => [
{ key: 'state', label: '상태', options: ['유효토큰', '세션 없음', '잠김', '만료', '종료'].map((state) => ({ label: state, value: state })), getValue: (row) => row.state } { key: 'state', label: '상태', options: [...new Set(sessions.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
], ],
[] [sessions]
); );
const openCreateModal = () => {
setCreateForm({
...emptyCreateForm,
organizationId: '',
licenseCode: (licensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
});
setCreateOpen(true);
};
const submitCreateUser = () => {
if (!createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8) return;
setCreateSaving(true);
createManagedUser({
email: createForm.email.trim(),
name: createForm.name.trim(),
password: createForm.password,
organizationId: createForm.organizationId || null,
department: createForm.department.trim() || null,
position: createForm.position.trim() || null,
licenseCode: createForm.licenseCode
})
.then(() => {
toast({ type: 'success', title: '사용자 등록', message: '사용자가 등록되었습니다.' });
setCreateOpen(false);
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '사용자 등록 실패', message: error.message });
})
.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="운영" title="사용자/세션" description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다." />
eyebrow="운영"
title="사용자 관리"
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
actions={
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
<Plus className="size-4" />
추가
</Button>
}
/>
{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
@@ -232,21 +124,7 @@ export function UserSessionsPage() {
pageSize={6} pageSize={6}
minWidth={900} minWidth={900}
loading={loading} loading={loading}
emptyMessage="표시할 사용자가 없습니다." emptyMessage="표시할 사용자/세션 데이터가 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
{menuSession ? (() => { {menuSession ? (() => {
const row = rows.find((item) => item.id === menuSession.id); const row = rows.find((item) => item.id === menuSession.id);
@@ -309,20 +187,8 @@ export function UserSessionsPage() {
</div> </div>
<StatusBadge status={detailSession.state} /> <StatusBadge status={detailSession.state} />
</div> </div>
<div className="grid gap-3 text-sm sm:grid-cols-2"> <div className="grid gap-3 text-sm">
<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 ?? '-'} />
@@ -336,96 +202,6 @@ export function UserSessionsPage() {
</div> </div>
) : null} ) : null}
</Modal> </Modal>
<Modal
open={createOpen}
title="사용자 추가"
onClose={() => {
if (!createSaving) setCreateOpen(false);
}}
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={createSaving}>취소</Button>
<Button
onClick={submitCreateUser}
disabled={createSaving || !createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8}
>
{createSaving ? '등록 중' : '등록'}
</Button>
</>
}
>
<div className="space-y-4">
<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={createForm.email}
onChange={(event) => setCreateForm((current) => ({ ...current, email: event.target.value }))}
/>
</label>
<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={createForm.name}
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <b className="text-foreground">*</b></span>
<input
type="password"
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={createForm.password}
onChange={(event) => setCreateForm((current) => ({ ...current, password: event.target.value }))}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">조직</span>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={createForm.organizationId}
onChange={(event) => setCreateForm((current) => ({ ...current, organizationId: event.target.value }))}
>
<option value="">없음</option>
{organizations.map((organization) => (
<option key={organization.id} value={organization.id}>{organization.name}</option>
))}
</select>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">부서</span>
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={createForm.department}
onChange={(event) => setCreateForm((current) => ({ ...current, department: event.target.value }))}
/>
</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 text-foreground outline-none focus:ring-2 focus:ring-ring"
value={createForm.position}
onChange={(event) => setCreateForm((current) => ({ ...current, position: event.target.value }))}
/>
</label>
</div>
<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={createForm.licenseCode}
onChange={(event) => setCreateForm((current) => ({ ...current, licenseCode: event.target.value as 'FREE' | 'EDU' | 'PRO' }))}
>
{licensePlans.map((plan) => (
<option key={plan.code} value={plan.code}>{plan.name}</option>
))}
{licensePlans.length === 0 ? <option value="EDU">EDU</option> : null}
</select>
</label>
</div>
</Modal>
<LoadingModal open={loadingVisible} /> <LoadingModal open={loadingVisible} />
</> </>
); );
+2 -2
View File
@@ -7,10 +7,10 @@ export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet
export function statusTone(status?: string | null): BadgeTone { export function statusTone(status?: string | null): BadgeTone {
if (!status) return 'neutral'; if (!status) return 'neutral';
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', '유효토큰', 'Running', 'ACTIVE'].includes(status)) return 'green'; if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber'; if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red'; if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
if (['없음', '세션 없음'].includes(status)) return 'neutral'; if (['없음'].includes(status)) return 'neutral';
if (['런처'].includes(status)) return 'violet'; if (['런처'].includes(status)) return 'violet';
return 'neutral'; return 'neutral';
} }
+12 -244
View File
@@ -1,28 +1,5 @@
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
export type PageParams = {
page?: number;
limit?: number;
keyword?: string;
[key: string]: string | number | undefined;
};
export type PagedResponse<T> = {
items: T[];
total: number;
page: number;
limit: number;
};
function pageQuery(params?: PageParams) {
const searchParams = new URLSearchParams();
Object.entries(params ?? {}).forEach(([key, value]) => {
if (value !== undefined && value !== '') searchParams.set(key, String(value));
});
const query = searchParams.toString();
return query ? `?${query}` : '';
}
export type AdminSession = { export type AdminSession = {
id?: string; id?: string;
revokedAt?: string | null; revokedAt?: string | null;
@@ -36,10 +13,8 @@ export type AdminSshKey = {
export type AdminAccount = { export type AdminAccount = {
id: string; id: string;
no?: number;
email: string; email: string;
name: string; name: string;
avatarUrl?: string | null;
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string; role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
isActive: boolean; isActive: boolean;
lastLoginAt?: string | null; lastLoginAt?: string | null;
@@ -70,7 +45,6 @@ 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;
@@ -89,13 +63,6 @@ export type Organization = {
name: string; name: string;
type: OrganizationType; type: OrganizationType;
domain?: string | null; domain?: string | null;
defaultLicenseCode?: string | null;
defaultLicenseName?: string | null;
instructorInviteCode: string;
createdById?: string | null;
createdByName?: string | null;
createdByEmail?: string | null;
createdByAvatarUrl?: string | null;
createdAt: string; createdAt: string;
}; };
@@ -111,61 +78,22 @@ export type LauncherNotice = {
endsAt?: string | null; endsAt?: string | null;
priority: number; priority: number;
status?: string; status?: string;
createdByName?: string | null;
createdByEmail?: string | null;
createdByAvatarUrl?: string | null;
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
}; };
export type LauncherTerm = { export async function loadAdmins() {
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>> {
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & { type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
appPermissions?: Array<string | { appId: string }>; appPermissions?: Array<string | { appId: string }>;
}; };
const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[]; total?: number; page?: number; limit?: number }>(`/admins${pageQuery(params)}`); const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[] }>('/admins');
const admins = Array.isArray(response) ? response : response.admins ?? []; const admins = Array.isArray(response) ? response : response.admins ?? [];
const items = admins.map((admin) => ({ return admins.map((admin) => ({
...admin, ...admin,
appPermissions: (admin.appPermissions ?? []).map((permission) => ( appPermissions: (admin.appPermissions ?? []).map((permission) => (
typeof permission === 'string' ? permission : permission.appId typeof permission === 'string' ? permission : permission.appId
)) ))
})); }));
if (Array.isArray(response)) return { items, total: items.length, page: params?.page ?? 1, limit: params?.limit ?? items.length };
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
}
export async function loadAdmins() {
return (await loadAdminsPage({ limit: 1000 })).items;
} }
export function loadAdminRoles() { export function loadAdminRoles() {
@@ -207,7 +135,7 @@ export function resetAdminPassword(adminId: string, temporaryPassword: string) {
export function createAdminSshKey( export function createAdminSshKey(
adminId: string, adminId: string,
payload: { label?: string; publicKey: string } payload: { label?: string; giteaUsername?: string; publicKey: string }
) { ) {
return apiRequest(`/admins/${adminId}/ssh-keys`, { return apiRequest(`/admins/${adminId}/ssh-keys`, {
method: 'POST', method: 'POST',
@@ -233,18 +161,13 @@ export function updateAdminAppPermissions(adminId: string, appIds: string[]) {
}); });
} }
export async function loadUserSessionsPage(params?: PageParams & { state?: string }): Promise<PagedResponse<UserSession>> { export async function loadUserSessions() {
const response = await apiRequest<{ sessions?: UserSession[]; total?: number; page?: number; limit?: number }>(`/users/sessions${pageQuery(params)}`); const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
const items = (response.sessions ?? []).map((session) => ({ return (response.sessions ?? []).map((session) => ({
...session, ...session,
session: session.sessionId ? session.session : '-', session: session.sessionId ? session.session : '-',
state: session.state state: session.state === '세션 없음' ? '없음' : session.state
})); }));
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
}
export async function loadUserSessions() {
return (await loadUserSessionsPage({ limit: 1000 })).items;
} }
export function lockUser(userId: string) { export function lockUser(userId: string) {
@@ -259,42 +182,15 @@ 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'
}); });
} }
export function createManagedUser(payload: {
email: string;
password: string;
name: string;
organizationId?: string | null;
department?: string | null;
position?: string | null;
licenseCode?: 'FREE' | 'EDU' | 'PRO';
}) {
return apiRequest<{ user: { id: string; email: string; name?: string | null } }>('/users', {
method: 'POST',
body: JSON.stringify(payload)
});
}
export async function loadOrganizationsPage(params?: PageParams & { type?: string }): Promise<PagedResponse<Organization>> {
const response = await apiRequest<{ organizations?: Organization[]; total?: number; page?: number; limit?: number }>(`/organizations${pageQuery(params)}`);
const items = response.organizations ?? [];
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
}
export async function loadOrganizations() { export async function loadOrganizations() {
return (await loadOrganizationsPage({ limit: 1000 })).items; const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
return response.organizations ?? [];
} }
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) { export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
@@ -317,21 +213,9 @@ export function deleteOrganization(organizationId: string) {
}); });
} }
export function updateOrganizationLicense(organizationId: string, licenseCode: string | null) {
return apiRequest<{ organization: Organization; updatedUserCount: number }>(`/organizations/${organizationId}/license`, {
method: 'PATCH',
body: JSON.stringify({ licenseCode })
});
}
export async function loadLauncherNoticesPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<LauncherNotice>> {
const response = await apiRequest<{ notices?: LauncherNotice[]; total?: number; page?: number; limit?: number }>(`/notices${pageQuery(params)}`);
const items = response.notices ?? [];
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
}
export async function loadLauncherNotices() { export async function loadLauncherNotices() {
return (await loadLauncherNoticesPage({ limit: 1000 })).items; const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
return response.notices ?? [];
} }
export function createLauncherNotice(payload: { export function createLauncherNotice(payload: {
@@ -375,61 +259,6 @@ 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;
@@ -475,19 +304,6 @@ export async function loadLicensePlans() {
return response.plans ?? []; return response.plans ?? [];
} }
export function updateLicensePlan(licenseCode: string, payload: {
name?: string;
description?: string | null;
defaultDurationDays?: number | null;
isActive?: boolean;
permissions?: Array<{ appKey: string; canLaunch: boolean }>;
}) {
return apiRequest<{ plan: LicensePlan; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}`, {
method: 'PATCH',
body: JSON.stringify(payload)
});
}
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) { export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.keyword) searchParams.set('keyword', params.keyword); if (params?.keyword) searchParams.set('keyword', params.keyword);
@@ -497,27 +313,6 @@ export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?
return response.users ?? []; return response.users ?? [];
} }
export async function loadLicenseAssignments(params?: PageParams & { licenseCode?: string; status?: string }): Promise<PagedResponse<LicenseAssignment>> {
const response = await apiRequest<{ assignments?: LicenseAssignment[]; total?: number; page?: number; limit?: number }>(`/licenses/assignments${pageQuery(params)}`);
const items = response.assignments ?? [];
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
}
export type LicenseAssignment = {
id: string;
no?: number;
userId: string;
user: string;
name: string;
role: string;
code: string;
licenseName: string;
startsAt: string;
expiresAt?: string | null;
assignedBy?: string | null;
status: string;
};
export function assignUserLicense(userId: string, payload: { export function assignUserLicense(userId: string, payload: {
licenseCode: string; licenseCode: string;
startsAt?: string; startsAt?: string;
@@ -559,33 +354,6 @@ export type LauncherUserAppPermission = {
assignedAt?: string | null; assignedAt?: string | null;
}; };
export type LicenseAppPermission = {
appId: string;
appKey: string;
displayName: string;
description?: string | null;
sortOrder: number;
isEnabled: boolean;
policyId?: string | null;
canView: boolean;
canLaunch: boolean;
};
export async function loadLicenseAppPermissions(licenseCode: string) {
const response = await apiRequest<{ permissions?: LicenseAppPermission[] }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`);
return response.permissions ?? [];
}
export function updateLicenseAppPermissions(licenseCode: string, permissions: Array<{
appKey: string;
canLaunch: boolean;
}>) {
return apiRequest<{ permissions?: LicenseAppPermission[]; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`, {
method: 'PUT',
body: JSON.stringify({ permissions })
});
}
export async function loadLauncherUserAppPermissions(userId: string) { export async function loadLauncherUserAppPermissions(userId: string) {
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`); const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
return response.permissions ?? []; return response.permissions ?? [];
+9 -72
View File
@@ -14,7 +14,6 @@ import {
createRuntimeProfile, createRuntimeProfile,
createRuntimeVariable, createRuntimeVariable,
deleteRuntimeIngressRule, deleteRuntimeIngressRule,
deleteRuntimeProfile,
deleteRuntimeVariable, deleteRuntimeVariable,
loadApps, loadApps,
loadRuntimeProfiles, loadRuntimeProfiles,
@@ -81,7 +80,6 @@ export function RuntimeConfigPage() {
const [actionKey, setActionKey] = useState(''); const [actionKey, setActionKey] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [profileModalOpen, setProfileModalOpen] = useState(false); const [profileModalOpen, setProfileModalOpen] = useState(false);
const [deleteProfileTarget, setDeleteProfileTarget] = useState<RuntimeEnvProfile | null>(null);
const [deleteVariableTarget, setDeleteVariableTarget] = useState<RuntimeEnvVariable | null>(null); const [deleteVariableTarget, setDeleteVariableTarget] = useState<RuntimeEnvVariable | null>(null);
const [deleteIngressTarget, setDeleteIngressTarget] = useState<RuntimeIngressRule | null>(null); const [deleteIngressTarget, setDeleteIngressTarget] = useState<RuntimeIngressRule | null>(null);
const loadingVisible = useMinimumLoading(loading); const loadingVisible = useMinimumLoading(loading);
@@ -118,7 +116,6 @@ export function RuntimeConfigPage() {
profile.environment === profileEnvironment && profile.environment === profileEnvironment &&
profile.namespace.trim() === profileNamespace.trim() profile.namespace.trim() === profileNamespace.trim()
)); ));
const showDuplicateProfileWarning = Boolean(profileName.trim() && duplicateProfile);
const refreshResources = (targetNamespace = namespace) => { const refreshResources = (targetNamespace = namespace) => {
if (!targetNamespace.trim()) { if (!targetNamespace.trim()) {
@@ -238,23 +235,6 @@ export function RuntimeConfigPage() {
.catch((createError: Error) => setError(createError.message)); .catch((createError: Error) => setError(createError.message));
}; };
const removeProfile = () => {
if (!deleteProfileTarget) return;
const target = deleteProfileTarget;
setActionKey(`profile-delete-${target.id}`);
deleteRuntimeProfile(target.id)
.then(() => {
toast({ type: 'success', title: '프로필 삭제', message: `${target.name} 프로필을 삭제했습니다.` });
setDeleteProfileTarget(null);
return refreshProfiles(appId);
})
.catch((deleteError: Error) => {
setError(deleteError.message);
toast({ type: 'error', title: '프로필 삭제 실패', message: deleteError.message });
})
.finally(() => setActionKey(''));
};
const addVariable = () => { const addVariable = () => {
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return; if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
const profile = selectedProfile; const profile = selectedProfile;
@@ -387,13 +367,6 @@ export function RuntimeConfigPage() {
}); });
}; };
const openProfileModal = () => {
setProfileName('');
setProfileEnvironment('dev');
setProfileNamespace(selectedApp?.namespace ?? '');
setProfileModalOpen(true);
};
return ( return (
<> <>
<PageTitle <PageTitle
@@ -401,7 +374,7 @@ export function RuntimeConfigPage() {
title="실행 설정" title="실행 설정"
description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다." description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다."
actions={( actions={(
<Button className="h-8 px-3 text-[13px]" onClick={openProfileModal} disabled={!appId}> <Button className="h-8 px-3 text-[13px]" onClick={() => setProfileModalOpen(true)} disabled={!appId}>
<Plus className="size-4" /> <Plus className="size-4" />
프로필 생성 프로필 생성
</Button> </Button>
@@ -441,15 +414,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>
@@ -502,19 +475,6 @@ export function RuntimeConfigPage() {
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span> <span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
</div> </div>
</div> </div>
<Button
variant="ghost"
className="size-8 shrink-0 self-end px-0 text-rose-300 hover:text-rose-200 lg:self-center"
title="프로필 삭제"
aria-label={`${profile.name} 프로필 삭제`}
disabled={Boolean(actionKey)}
onClick={(event) => {
event.stopPropagation();
setDeleteProfileTarget(profile);
}}
>
<Trash2 className="size-4" />
</Button>
</div> </div>
</div> </div>
))} ))}
@@ -739,24 +699,24 @@ export function RuntimeConfigPage() {
footer={( footer={(
<> <>
<Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button> <Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button>
<Button disabled={!profileName.trim() || !profileNamespace.trim() || showDuplicateProfileWarning} onClick={addProfile}>생성</Button> <Button disabled={!profileName.trim() || !profileNamespace.trim() || Boolean(duplicateProfile)} onClick={addProfile}>생성</Button>
</> </>
)} )}
> >
<div className="space-y-4"> <div className="space-y-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">프로필명</span>
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" /> <input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" />
</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">환경</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={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}> <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={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}>
<option value="dev">개발</option> <option value="dev">개발</option>
<option value="prod">운영</option> <option value="prod">운영</option>
</select> </select>
</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">네임스페이스</span>
<input <input
list="runtime-namespace-options" list="runtime-namespace-options"
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
@@ -768,36 +728,13 @@ export function RuntimeConfigPage() {
{namespaceOptions.map((item) => <option key={item} value={item} />)} {namespaceOptions.map((item) => <option key={item} value={item} />)}
</datalist> </datalist>
</label> </label>
{showDuplicateProfileWarning ? ( {duplicateProfile ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200"> <p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요. 이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.
</p> </p>
) : null} ) : null}
</div> </div>
</Modal> </Modal>
<Modal
open={Boolean(deleteProfileTarget)}
title="프로필 삭제"
onClose={() => {
if (!actionKey) setDeleteProfileTarget(null);
}}
footer={(
<>
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteProfileTarget(null)}>취소</Button>
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={Boolean(actionKey)} onClick={removeProfile}>
{actionKey.startsWith('profile-delete-') ? '삭제 중' : '삭제'}
</Button>
</>
)}
>
<div className="space-y-3 text-sm">
<p>
<span className="font-semibold text-foreground">{deleteProfileTarget?.name ?? '-'}</span>
{' '}프로필을 삭제합니다.
</p>
<p className="text-muted-foreground">프로필에 등록된 설정값과 접속 설정도 함께 삭제됩니다.</p>
</div>
</Modal>
<Modal <Modal
open={Boolean(deleteVariableTarget)} open={Boolean(deleteVariableTarget)}
title="설정값 삭제" title="설정값 삭제"
+76 -181
View File
@@ -1,191 +1,86 @@
import { MoreHorizontal, RotateCcw } from 'lucide-react'; import { Activity, MoreHorizontal, Search, SlidersHorizontal } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { Badge, Button, Card, CardHeader } from '../../components/ui';
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 { loadPlatformDevices, type PlatformDevice } from '../agents/platform-api'; import { agentDevices } from '../../data/mock';
type HeadscaleNode = { function getTone(status: string) {
id: string; if (['정상', '활성', '성공', '운영', 'Running'].includes(status)) return 'green';
name: string; if (['주의', '예약됨', '대기', '준비중', '카나리', 'Pending'].includes(status)) return 'amber';
ip: string; if (['오류', '실패', '비활성', '만료', 'Error', 'Failed'].includes(status)) return 'red';
ips?: string[]; if (['검수', '동기화'].includes(status)) return 'blue';
role: string; return 'neutral';
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 <PageTitle eyebrow="Agent" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
eyebrow="플랫폼 관리" <Card className="min-w-0 overflow-hidden">
title="연결 제어" <CardHeader
description="Headscale 노드 연결 상태와 ACL 기반 제어 권한을 확인합니다." title="장비 목록"
actions={ description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}> action={
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} /> <div className="flex gap-2">
새로고침 <Button variant="outline" className="h-8 px-3 text-[13px]">
</Button> <SlidersHorizontal className="size-4" />
} 상태 필터
/> </Button>
<DataTableCard <Button className="h-8 px-3 text-[13px]">
title="연결 플랫폼" <Activity className="size-4" />
description="Headscale에 등록된 노드와 해당 노드에 적용된 ACL 제어 사용자를 표시합니다." 상태 갱신
rows={rows} </Button>
columns={columns} </div>
searchPlaceholder="플랫폼, 사용자, IP, ACL 검색" }
getSearchText={(row) => `${row.name} ${row.user} ${row.ip} ${row.controllers.join(' ')} ${row.routes}`} />
getRowKey={(row) => row.id || row.name} <div className="border-b border-border px-5 pb-4">
pageSize={6} <div className="relative max-w-md">
minWidth={1100} <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
loading={loading} <input
emptyMessage="Headscale에 연결된 플랫폼이 없습니다." className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
/> placeholder="장비명, 계정, 워크스페이스 검색"
<Modal open={Boolean(selected)} title="플랫폼 연결 상세" onClose={() => setSelected(null)}> />
<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>
<dt className="text-muted-foreground">Headscale 사용자</dt><dd className="min-w-0 break-all">{selected?.user || '-'}</dd> <div className="max-h-[620px] overflow-x-auto overflow-y-auto">
<dt className="text-muted-foreground">연결 IP</dt><dd className="font-mono text-xs">{selected?.ips?.join(', ') || selected?.ip || '-'}</dd> <table className="w-full min-w-[1040px] text-left text-sm">
<dt className="text-muted-foreground">ACL 제어 사용자</dt><dd className="min-w-0 break-all">{selected?.controllers.length ? selected.controllers.join(', ') : '개별 ACL 규칙 없음'}</dd> <thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<dt className="text-muted-foreground">연결 경로</dt><dd className="min-w-0 break-all">{selected?.routes || '-'}</dd> <tr>
<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">계정</th>
<dt className="text-muted-foreground">사용 유형</dt><dd>{selected?.device?.usageType || '-'}</dd> <th className="px-5 py-3 font-medium">Workspace</th>
<dt className="text-muted-foreground">장비 유형</dt><dd>{selected ? ({ agent: '에이전트', launcher: '런처 클라이언트', unknown: '미확인' }[getDeviceKind(selected)]) : '-'}</dd> <th className="px-5 py-3 font-medium">SSH 키</th>
<dt className="text-muted-foreground">에이전트 버전</dt><dd>{selected?.device?.agentVersion || '-'}</dd> <th className="px-5 py-3 font-medium">Last Seen</th>
<dt className="text-muted-foreground">마지막 에이전트 확인</dt><dd>{formatDateTime(selected?.device?.agentLastSeenAt)}</dd> <th className="px-5 py-3 font-medium">상태</th>
</dl> <th className="px-5 py-3 text-right font-medium">명령</th>
</Modal> </tr>
</thead>
<tbody className="divide-y divide-border">
{agentDevices.map((device) => (
<tr key={device.name} className="hover:bg-muted/20">
<td className="px-5 py-3 font-medium">{device.name}</td>
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
<td className="px-5 py-3">{device.sshKey}</td>
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
<td className="px-5 py-3">
<Badge tone={getTone(device.status)}>{device.status}</Badge>
</td>
<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>
</> </>
); );
} }
+8 -64
View File
@@ -16,25 +16,6 @@ function eventTone(type?: string) {
return 'neutral'; return 'neutral';
} }
const eventRanges = [
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
{ label: '전체', value: 'all', milliseconds: null }
];
function eventTime(event: RuntimeEvent) {
const value = event.lastTimestamp ?? event.firstTimestamp;
if (!value) return 0;
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : 0;
}
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
if (!range.milliseconds) return true;
const time = eventTime(event);
return time > 0 && time >= Date.now() - range.milliseconds;
}
function apiKind(kind: string) { function apiKind(kind: string) {
return kind.toLowerCase(); return kind.toLowerCase();
} }
@@ -147,7 +128,6 @@ export function RuntimeLogsPage() {
const [tail, setTail] = useState(100); const [tail, setTail] = useState(100);
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs'); const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [eventRange, setEventRange] = useState('1h');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [logLoading, setLogLoading] = useState(false); const [logLoading, setLogLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -167,9 +147,7 @@ export function RuntimeLogsPage() {
}, [podMetrics]); }, [podMetrics]);
const loggableResources = resources.filter(isActiveLogTarget); const loggableResources = resources.filter(isActiveLogTarget);
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined; const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
const filteredEvents = events.filter((event) => { const filteredEvents = events.filter((event) => {
if (!isEventInRange(event, selectedEventRange)) return false;
const normalizedSearch = search.trim().toLowerCase(); const normalizedSearch = search.trim().toLowerCase();
if (!normalizedSearch) return true; if (!normalizedSearch) return true;
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch); return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
@@ -239,26 +217,6 @@ export function RuntimeLogsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
if (activeTab !== 'events' || !namespace.trim()) return;
let cancelled = false;
const loadEvents = () => {
loadRuntimeEvents(namespace.trim())
.then((nextEvents) => {
if (!cancelled) setEvents(nextEvents);
})
.catch(() => undefined);
};
loadEvents();
const timer = window.setInterval(loadEvents, 10_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [activeTab, namespace]);
return ( return (
<> <>
<PageTitle <PageTitle
@@ -405,28 +363,14 @@ export function RuntimeLogsPage() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center"> <div className="relative w-full lg:w-80">
<div className="flex flex-wrap gap-2"> <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
{eventRanges.map((range) => ( <input
<Button className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
key={range.value} placeholder="이벤트 검색"
variant={eventRange === range.value ? 'secondary' : 'outline'} value={search}
className="h-8 px-3 text-[13px]" onChange={(event) => setSearch(event.target.value)}
onClick={() => setEventRange(range.value)} />
>
{range.label}
</Button>
))}
</div>
<div className="relative w-full lg:w-80">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
placeholder="이벤트 검색"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
</div> </div>
)} )}
</div> </div>
-12
View File
@@ -18,12 +18,6 @@ export function createRuntimeProfile(input: { appId: string; name: string; envir
}); });
} }
export function deleteRuntimeProfile(profileId: string) {
return apiRequest<{ deleted: boolean; profileId: string; name: string }>(`/apps/runtime-profiles/${profileId}`, {
method: 'DELETE'
});
}
export function createRuntimeVariable( export function createRuntimeVariable(
profileId: string, profileId: string,
input: { componentId: string; key: string; value: string; isSecret: boolean } input: { componentId: string; key: string; value: string; isSecret: boolean }
@@ -66,12 +60,6 @@ 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}`);
+7 -68
View File
@@ -100,7 +100,6 @@ export function MonitoringPage() {
const selectedApp = apps.find((app) => app.id === selectedAppId) ?? apps[0]; const selectedApp = apps.find((app) => app.id === selectedAppId) ?? apps[0];
const selectedRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[1]; const selectedRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[1];
const visibleResources = useMemo(() => resources.filter((resource) => !isCompletedMigration(resource)), [resources]); const visibleResources = useMemo(() => resources.filter((resource) => !isCompletedMigration(resource)), [resources]);
const showInitialSkeleton = loading && resources.length === 0 && events.length === 0;
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
const threshold = Date.now() - selectedRange.milliseconds; const threshold = Date.now() - selectedRange.milliseconds;
return events.filter((event) => eventTime(event) >= threshold); return events.filter((event) => eventTime(event) >= threshold);
@@ -259,16 +258,10 @@ export function MonitoringPage() {
</Card> </Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{showInitialSkeleton ? ( <MetricCard icon={Server} label="워크로드" value={`${summary.healthy}/${summary.total}`} hint="Pod/Deployment/StatefulSet 기준" tone={summary.healthy === summary.total ? 'green' : 'amber'} />
Array.from({ length: 4 }).map((_, index) => <MetricCardSkeleton key={index} />) <MetricCard icon={Activity} label="Pod" value={String(visibleResources.filter((resource) => resource.kind === 'Pod').length)} hint="현재 기준" tone="blue" />
) : ( <MetricCard icon={AlertTriangle} label="Warning 이벤트" value={String(summary.warnings)} hint={`${selectedRange.label} 기준`} tone={summary.warnings > 0 ? 'amber' : 'green'} />
<> <MetricCard icon={RotateCcw} label="재시작" value={String(summary.restarts)} hint="현재 Pod 누적" tone={summary.restarts > 0 ? 'amber' : 'green'} />
<MetricCard icon={Server} label="워크로드" value={`${summary.healthy}/${summary.total}`} hint="Pod/Deployment/StatefulSet 기준" tone={summary.healthy === summary.total ? 'green' : 'amber'} />
<MetricCard icon={Activity} label="Pod" value={String(visibleResources.filter((resource) => resource.kind === 'Pod').length)} hint="현재 기준" tone="blue" />
<MetricCard icon={AlertTriangle} label="Warning 이벤트" value={String(summary.warnings)} hint={`${selectedRange.label} 기준`} tone={summary.warnings > 0 ? 'amber' : 'green'} />
<MetricCard icon={RotateCcw} label="재시작" value={String(summary.restarts)} hint="현재 Pod 누적" tone={summary.restarts > 0 ? 'amber' : 'green'} />
</>
)}
</div> </div>
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(360px,0.75fr)]"> <div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(360px,0.75fr)]">
@@ -320,9 +313,7 @@ export function MonitoringPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader title="리소스 상태" description="선택한 앱 네임스페이스의 주요 리소스입니다." /> <CardHeader title="리소스 상태" description="선택한 앱 네임스페이스의 주요 리소스입니다." />
<div className="max-h-[420px] divide-y divide-border overflow-y-auto"> <div className="max-h-[420px] divide-y divide-border overflow-y-auto">
{showInitialSkeleton ? ( {visibleResources.map((resource) => (
<ListSkeleton rows={6} />
) : visibleResources.map((resource) => (
<div key={`${resource.kind}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-3 text-sm"> <div key={`${resource.kind}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-3 text-sm">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -340,9 +331,7 @@ export function MonitoringPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader title="이벤트 목록" description="선택한 기간의 Warning 이벤트를 우선 확인합니다." /> <CardHeader title="이벤트 목록" description="선택한 기간의 Warning 이벤트를 우선 확인합니다." />
<div className="max-h-[420px] divide-y divide-border overflow-y-auto"> <div className="max-h-[420px] divide-y divide-border overflow-y-auto">
{showInitialSkeleton ? ( {filteredEvents.slice(0, 12).map((event, index) => (
<ListSkeleton rows={5} />
) : filteredEvents.slice(0, 12).map((event, index) => (
<div key={`${event.involvedObject}-${event.reason}-${index}`} className="px-5 py-3 text-sm"> <div key={`${event.involvedObject}-${event.reason}-${index}`} className="px-5 py-3 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge tone={event.type === 'Warning' ? 'amber' : 'neutral'}>{event.type ?? '-'}</Badge> <Badge tone={event.type === 'Warning' ? 'amber' : 'neutral'}>{event.type ?? '-'}</Badge>
@@ -351,7 +340,7 @@ export function MonitoringPage() {
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{event.message ?? '-'}</p> <p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{event.message ?? '-'}</p>
</div> </div>
))} ))}
{!showInitialSkeleton && !filteredEvents.length ? <div className="px-5 py-12 text-center text-sm text-muted-foreground">선택 기간에 표시할 이벤트가 없습니다.</div> : null} {!filteredEvents.length ? <div className="px-5 py-12 text-center text-sm text-muted-foreground">선택 기간에 표시할 이벤트가 없습니다.</div> : null}
</div> </div>
</Card> </Card>
</div> </div>
@@ -361,56 +350,6 @@ export function MonitoringPage() {
); );
} }
function SkeletonBlock({ className }: { className: string }) {
return (
<div
className={cn(
'relative overflow-hidden rounded-md bg-white/15',
'before:absolute before:inset-0 before:-translate-x-full before:animate-[skeleton-shimmer_1.35s_ease-in-out_infinite]',
'before:bg-gradient-to-r before:from-transparent before:via-white/35 before:to-transparent',
className
)}
/>
);
}
function MetricCardSkeleton() {
return (
<Card className="p-5">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-20" />
<SkeletonBlock className="size-4 rounded-full" />
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<SkeletonBlock className="h-9 w-24" />
<SkeletonBlock className="h-6 w-12" />
</div>
<SkeletonBlock className="mt-3 h-3 w-36" />
</Card>
);
}
function ListSkeleton({ rows }: { rows: number }) {
return (
<>
{Array.from({ length: rows }).map((_, index) => (
<div key={index} className="px-5 py-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<SkeletonBlock className="size-2.5 rounded-full" />
<SkeletonBlock className="h-4 w-3/5" />
</div>
<SkeletonBlock className="mt-2 h-3 w-2/5" />
</div>
<SkeletonBlock className="h-6 w-16" />
</div>
</div>
))}
</>
);
}
function MetricCard({ icon: Icon, label, value, hint, tone }: { icon: typeof Server; label: string; value: string; hint: string; tone: 'green' | 'amber' | 'red' | 'blue' | 'neutral' }) { function MetricCard({ icon: Icon, label, value, hint, tone }: { icon: typeof Server; label: string; value: string; hint: string; tone: 'green' | 'amber' | 'red' | 'blue' | 'neutral' }) {
return ( return (
<Card className="p-5"> <Card className="p-5">
-15
View File
@@ -21,14 +21,9 @@ 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;
requestedBy?: string | null;
requestedByName?: string | null;
requestedByEmail?: string | null;
requestedByAvatarUrl?: string | null;
startedAt?: string | null; startedAt?: string | null;
finishedAt?: string | null; finishedAt?: string | null;
createdAt: string; createdAt: string;
@@ -45,10 +40,6 @@ export type AppRelease = {
imageTags?: unknown; imageTags?: unknown;
status: JobStatus; status: JobStatus;
logs?: string | null; logs?: string | null;
requestedBy?: string | null;
requestedByName?: string | null;
requestedByEmail?: string | null;
requestedByAvatarUrl?: string | null;
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
app: ApiApp; app: ApiApp;
@@ -62,9 +53,6 @@ export type DeploymentJob = {
namespace: string; namespace: string;
status: JobStatus; status: JobStatus;
requestedBy?: string | null; requestedBy?: string | null;
requestedByName?: string | null;
requestedByEmail?: string | null;
requestedByAvatarUrl?: string | null;
logs?: string | null; logs?: string | null;
startedAt?: string | null; startedAt?: string | null;
finishedAt?: string | null; finishedAt?: string | null;
@@ -81,9 +69,6 @@ export type RuntimeControlJob = {
action: string; action: string;
status: JobStatus; status: JobStatus;
requestedBy?: string | null; requestedBy?: string | null;
requestedByName?: string | null;
requestedByEmail?: string | null;
requestedByAvatarUrl?: string | null;
request?: Record<string, unknown> | null; request?: Record<string, unknown> | null;
result?: Record<string, unknown> | null; result?: Record<string, unknown> | null;
logs?: string | null; logs?: string | null;
-1
View File
@@ -21,7 +21,6 @@ export type ApiAppComponent = {
name?: string; name?: string;
port?: number; port?: number;
targetPort?: number; targetPort?: number;
nodePort?: number;
protocol?: string; protocol?: string;
}> | null; }> | null;
buildJobs?: Array<{ buildJobs?: Array<{