From fedf9481f8c75a33699e3546c9deeb15ff58fbe6 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 2 Jul 2026 14:36:24 +0900 Subject: [PATCH] add launcher components management page --- src/App.tsx | 6 + src/data/mock.ts | 1 + src/layout/navigation.ts | 1 + src/pages/launcher/LauncherComponentsPage.tsx | 325 ++++++++++++++++++ src/pages/launcher/launcher-api.ts | 18 + 5 files changed, 351 insertions(+) create mode 100644 src/pages/launcher/LauncherComponentsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index a3eb207..8b8992e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -70,6 +70,7 @@ import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage'; import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage'; import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage'; import { LauncherCommonSettingsPage as LauncherCommonSettingsApiPage } from './pages/launcher/LauncherCommonSettingsPage'; +import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage'; import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage'; import { AgentCommandsPage as AgentCommandsRestoredPage, @@ -92,6 +93,7 @@ type ViewKey = | 'launcherSources' | 'launcherBuilds' | 'launcherVersions' + | 'launcherComponents' | 'launcherApps' | 'launcherCommonSettings' | 'notices' @@ -123,6 +125,7 @@ type ViewKey = const viewByLabel: Record = { 대시보드: 'dashboard', '버전 관리': 'launcherVersions', + 구성요소: 'launcherComponents', '앱 연결': 'launcherApps', '공통 설정': 'launcherCommonSettings', 공지사항: 'notices', @@ -157,6 +160,7 @@ const pathByView: Record = { launcherSources: '/launcher/sources', launcherBuilds: '/launcher/builds', launcherVersions: '/launcher/versions', + launcherComponents: '/launcher/components', launcherApps: '/launcher/apps', launcherCommonSettings: '/launcher/common-settings', notices: '/operations/notices', @@ -3349,6 +3353,8 @@ function App() { switch (activeView) { case 'launcherVersions': return ; + case 'launcherComponents': + return ; case 'launcherSources': return ; case 'launcherBuilds': diff --git a/src/data/mock.ts b/src/data/mock.ts index 81410e6..ec8c5f3 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -25,6 +25,7 @@ export const navGroups = [ { label: '소스 관리', view: 'launcherSources' }, { label: '빌드 관리', view: 'launcherBuilds' }, { label: '버전 관리' }, + { label: '구성요소', view: 'launcherComponents' }, { label: '공통 설정' }, { label: '앱 연결' } ] diff --git a/src/layout/navigation.ts b/src/layout/navigation.ts index 471ac4e..afd591f 100644 --- a/src/layout/navigation.ts +++ b/src/layout/navigation.ts @@ -41,6 +41,7 @@ export const navGroups: NavGroup[] = [ { label: '소스 관리', path: '/launcher/sources' }, { label: '빌드 관리', path: '/launcher/builds' }, { label: '버전 관리', path: '/launcher/versions' }, + { label: '구성요소', path: '/launcher/components' }, { label: '공통 설정', path: '/launcher/common-settings' }, { label: '앱 연결', path: '/launcher/apps' } ] diff --git a/src/pages/launcher/LauncherComponentsPage.tsx b/src/pages/launcher/LauncherComponentsPage.tsx new file mode 100644 index 0000000..ad543ec --- /dev/null +++ b/src/pages/launcher/LauncherComponentsPage.tsx @@ -0,0 +1,325 @@ +import { Database, FileArchive, RotateCcw, Server, TerminalSquare } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; +import { useToast } from '../../components/toast'; +import { PageTitle } from '../../layout/AppLayout'; +import { cn } from '../../lib/utils'; +import type { RuntimeResource } from '../../types/runtime'; +import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; +import { + listLauncherBuildJobs, + listLauncherVersions, + loadLauncherComponentLogs, + loadLauncherRuntimeResources, + restartLauncherComponent, + type LauncherBuildJob, + type LauncherVersion +} from './launcher-api'; + +const LAUNCHER_NAMESPACE = 'studio-system'; + +type LauncherComponentDefinition = { + key: string; + name: string; + description: string; + type: 'client' | 'backend' | 'database'; + kind?: string; + workloadName?: string; + serviceName?: string; +}; + +const launcherComponents: LauncherComponentDefinition[] = [ + { + key: 'launcher-client', + name: '런처 클라이언트', + description: '사용자 PC에 설치되는 Electron .deb 패키지입니다.', + type: 'client' + }, + { + key: 'launcher-be', + name: '런처 백엔드', + description: '로그인, 앱 목록, 업데이트, handoff API를 제공하는 서버입니다.', + type: 'backend', + kind: 'Deployment', + workloadName: 'studio-launcher-be', + serviceName: 'studio-launcher-be' + }, + { + key: 'launcher-db', + name: '런처 DB', + description: '런처 앱, 버전, 사용자 앱 권한 데이터를 저장하는 PostgreSQL입니다.', + type: 'database', + kind: 'StatefulSet', + workloadName: 'studio-launcher-db', + serviceName: 'studio-launcher-db' + } +]; + +function statusTone(status?: string): 'green' | 'amber' | 'red' | 'neutral' { + if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'green'; + if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'amber'; + if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'red'; + return 'neutral'; +} + +function componentIcon(type: LauncherComponentDefinition['type']) { + if (type === 'client') return FileArchive; + if (type === 'database') return Database; + return Server; +} + +function apiKind(kind?: string) { + return (kind ?? '').toLowerCase(); +} + +function stripAnsi(value: string) { + return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, ''); +} + +function findResource(resources: RuntimeResource[], kind?: string, name?: string) { + if (!kind || !name) return undefined; + return resources.find((resource) => resource.kind === kind && resource.name === name); +} + +function serviceResource(resources: RuntimeResource[], name?: string) { + if (!name) return undefined; + return resources.find((resource) => resource.kind === 'Service' && resource.name === name); +} + +function relatedResources(resources: RuntimeResource[], component: LauncherComponentDefinition) { + if (component.type === 'client') return []; + const names = new Set([component.workloadName, component.serviceName].filter(Boolean)); + return resources.filter((resource) => { + if (names.has(resource.name)) return true; + return component.workloadName ? resource.name.includes(component.workloadName) : false; + }); +} + +export function LauncherComponentsPage() { + const { toast } = useToast(); + const [versions, setVersions] = useState([]); + const [buildJobs, setBuildJobs] = useState([]); + const [resources, setResources] = useState([]); + const [loading, setLoading] = useState(true); + const [actionKey, setActionKey] = useState(''); + const [logTarget, setLogTarget] = useState(null); + const [logs, setLogs] = useState(''); + const [logLoading, setLogLoading] = useState(false); + const loadingVisible = useMinimumLoading(loading || logLoading); + + const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]); + const latestBuild = useMemo(() => buildJobs[0], [buildJobs]); + + const refresh = () => { + setLoading(true); + Promise.all([ + listLauncherVersions().catch(() => []), + listLauncherBuildJobs().catch(() => []), + loadLauncherRuntimeResources(LAUNCHER_NAMESPACE) + ]) + .then(([nextVersions, nextBuildJobs, runtime]) => { + setVersions(nextVersions); + setBuildJobs(nextBuildJobs); + setResources(runtime.resources ?? []); + }) + .catch((error: Error) => { + toast({ type: 'error', title: '런처 구성요소 조회 실패', message: error.message }); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + refresh(); + }, []); + + const openLogs = (component: LauncherComponentDefinition) => { + if (!component.kind || !component.workloadName) return; + setLogTarget(component); + setLogs(''); + setLogLoading(true); + loadLauncherComponentLogs(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName) + .then((result) => setLogs(stripAnsi(result.logs || '로그가 없습니다.'))) + .catch((error: Error) => setLogs(`[warn] 로그 조회 실패\n${error.message}`)) + .finally(() => setLogLoading(false)); + }; + + const restart = (component: LauncherComponentDefinition) => { + if (!component.kind || !component.workloadName) return; + setActionKey(component.key); + restartLauncherComponent(LAUNCHER_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(() => setActionKey('')); + }; + + return ( + <> + + + 새로고침 + + } + /> + +
+ +

런처 최신 버전

+

{activeVersion?.version ?? '-'}

+

{activeVersion?.downloadUrl ?? '등록된 릴리즈 없음'}

+
+ +

최근 클라이언트 빌드

+

{latestBuild?.version ?? '-'}

+

{latestBuild?.status ?? '빌드 이력 없음'}

+
+ +

Namespace

+

{LAUNCHER_NAMESPACE}

+

{resources.length}개 관련 리소스 조회

+
+
+ +
+ {launcherComponents.map((component) => { + const Icon = componentIcon(component.type); + const workload = findResource(resources, component.kind, component.workloadName); + const service = serviceResource(resources, component.serviceName); + const details = relatedResources(resources, component); + const isClient = component.type === 'client'; + const status = isClient ? (activeVersion ? '배포 등록' : '미등록') : workload?.status ?? 'NotFound'; + + return ( + + {status}} + /> +
+
+
+ + + +
+

{component.key}

+

{isClient ? 'Client Package' : `${component.kind}/${component.workloadName}`}

+
+
+ +
+ {isClient ? ( + <> +
+

Artifact

+

{activeVersion?.artifact ?? '-'}

+
+
+

Repository

+

{activeVersion?.repository ?? '-'}

+
+ + ) : ( + <> +
+

Ready

+

{workload?.ready ?? '-'}

+
+
+

Service

+

{service ? `${service.status} · ${service.detail}` : '-'}

+
+
+

Image

+

{workload?.detail ?? '-'}

+
+ + )} +
+ + {!isClient ? ( +
+ + +
+ ) : null} +
+ +
+ + + + + + + + + + + + + {isClient ? ( + + + + + + + + + ) : null} + {!isClient && details.length === 0 ? ( + + + + ) : null} + {!isClient && details.map((resource) => ( + + + + + + + + + ))} + +
KindNameStatusReadyAgeDetail
Release{activeVersion?.version ?? '-'}{activeVersion?.isActive ? 'Active' : '-'}-{activeVersion?.createdAt ? new Date(activeVersion.createdAt).toLocaleString() : '-'}{activeVersion?.downloadUrl ?? '-'}
관련 리소스가 없습니다.
{resource.kind}{resource.name}{resource.status}{resource.ready}{resource.age}{resource.detail || '-'}
+
+
+
+ ); + })} +
+ + setLogTarget(null)} + className="!max-w-5xl" + > +
+          {logs || (logLoading ? '로그를 불러오는 중...' : '로그가 없습니다.')}
+        
+
+ + + + ); +} diff --git a/src/pages/launcher/launcher-api.ts b/src/pages/launcher/launcher-api.ts index 4311377..5b211aa 100644 --- a/src/pages/launcher/launcher-api.ts +++ b/src/pages/launcher/launcher-api.ts @@ -1,6 +1,7 @@ import { apiRequest } from '../../lib/api'; import type { AppRelease, AppWithComponents } from '../../types/application'; import type { GiteaRepo } from '../../types/application'; +import type { RuntimeResourceResponse, RuntimeWorkloadLogResponse } from '../../types/runtime'; export type LauncherVersion = { id: string; @@ -250,3 +251,20 @@ export function updateLauncherCommonSetting( export function deleteLauncherCommonSetting(id: string) { return apiRequest<{ deleted: boolean }>(`/launcher/common-settings/${id}`, { method: 'DELETE' }); } + +export function loadLauncherRuntimeResources(namespace = 'studio-system') { + return apiRequest(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`); +} + +export function loadLauncherComponentLogs(namespace: string, kind: string, name: string, tail = 120) { + return apiRequest( + `/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind.toLowerCase())}/${encodeURIComponent(name)}/logs?tail=${tail}` + ); +} + +export function restartLauncherComponent(namespace: string, kind: string, name: string) { + return apiRequest<{ ok: boolean; action: string; target: string; namespace: string }>( + `/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind.toLowerCase())}/${encodeURIComponent(name)}/restart`, + { method: 'POST' } + ); +}