import { useEffect, useState, type MouseEvent } from 'react'; import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react'; import { Badge, Button, Card, CardHeader } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api'; import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin } from '../../lib/admin-app-permissions'; import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot'; import { cn } from '../../lib/utils'; import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app'; import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime'; import { LoadingModal } from '../operations/operation-utils'; type ComponentPortForm = { name: string; port: string; targetPort: string; protocol: string; }; type BuildEnvVariableForm = { id?: string; key: string; value: string; isSecret: boolean; }; const emptyComponentPort = (): ComponentPortForm => ({ name: '', port: '', targetPort: '', protocol: 'TCP' }); const emptyBuildEnvVariable = (): BuildEnvVariableForm => ({ key: '', value: '', isSecret: false }); function useMinimumLoading(loading: boolean, minimumMs = 500) { const [visible, setVisible] = useState(loading); useEffect(() => { if (loading) { setVisible(true); return; } const timer = window.setTimeout(() => setVisible(false), minimumMs); return () => window.clearTimeout(timer); }, [loading, minimumMs]); return visible; } function isPageAgentClick(event: MouseEvent) { return !event.nativeEvent.isTrusted; } function blockPageAgentDestructiveClick(event: MouseEvent) { if (!isPageAgentClick(event)) return false; event.preventDefault(); event.stopPropagation(); return true; } function getTone(status: string) { if (['활성', '완료', '게시됨', '실행 중', '배포됨', 'ACTIVE', 'READY'].includes(status)) return 'green'; if (['주의', '예약됨', '대기', '준비중', '카나리'].includes(status)) return 'amber'; if (['잠김', '실패', '오프라인', '비활성'].includes(status)) return 'red'; if (['진행중', '동기화'].includes(status)) return 'blue'; return 'neutral'; } function formatStatus(status?: string | null) { const statusMap: Record = { ACTIVE: '준비됨', READY: '준비됨', INACTIVE: '비활성', DISABLED: '비활성', ERROR: '오류' }; return status ? statusMap[status] ?? status : '-'; } function loadApps() { return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() }) .then((response) => { if (!response.ok) { throw new Error('Apps request failed'); } return response.json() as Promise; }) .then(filterAppsByCurrentAdmin); } function loadRuntimeResources(namespace: string) { return apiFetch(`${apiBaseUrl}/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`, { headers: authHeaders() }) .then((response) => { if (!response.ok) throw new Error('Runtime resources request failed'); return response.json() as Promise<{ resources: RuntimeResource[] }>; }); } function controlRuntimeWorkload(namespace: string, kind: string, name: string, action: 'restart' | 'start' | 'stop') { const apiKind = kind === 'Deployment' ? 'deployment' : kind === 'StatefulSet' ? 'statefulset' : kind === 'DaemonSet' ? 'daemonset' : ''; if (!apiKind) return Promise.reject(new Error('지원하지 않는 워크로드 유형입니다.')); const path = action === 'restart' ? `/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(apiKind)}/${encodeURIComponent(name)}/restart` : `/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(apiKind)}/${encodeURIComponent(name)}/scale`; return apiFetch(`${apiBaseUrl}${path}`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: action === 'restart' ? undefined : JSON.stringify({ replicas: action === 'start' ? 1 : 0 }) }).then((response) => { if (!response.ok) throw new Error('워크로드 제어 요청이 실패했습니다.'); return response.json().catch(() => null); }); } function deleteRuntimeWorkload(namespace: string, kind: string, name: string, deleteService: boolean) { const apiKind = kind === 'Deployment' ? 'deployment' : kind === 'StatefulSet' ? 'statefulset' : kind === 'DaemonSet' ? 'daemonset' : ''; if (!apiKind) return Promise.reject(new Error('지원하지 않는 워크로드 유형입니다.')); const query = deleteService ? '?deleteService=true' : ''; return apiFetch(`${apiBaseUrl}/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(apiKind)}/${encodeURIComponent(name)}${query}`, { method: 'DELETE', headers: authHeaders() }).then(async (response) => { if (!response.ok) { const body = await response.json().catch(() => null); const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message; throw new Error(message || '워크로드 삭제 요청이 실패했습니다.'); } return response.json().catch(() => null); }); } function loadWorkloadPersistentVolumeClaims(namespace: string, kind: string, name: string) { const apiKind = kind === 'StatefulSet' ? 'statefulset' : ''; if (!apiKind) return Promise.reject(new Error('StatefulSet 데이터 볼륨만 조회할 수 있습니다.')); return apiFetch(`${apiBaseUrl}/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(apiKind)}/${encodeURIComponent(name)}/pvcs`, { headers: authHeaders() }).then(async (response) => { if (!response.ok) { const body = await response.json().catch(() => null); const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message; throw new Error(message || '데이터 볼륨 조회 요청이 실패했습니다.'); } return response.json() as Promise<{ claims: RuntimePersistentVolumeClaim[] }>; }); } function deleteWorkloadPersistentVolumeClaim(namespace: string, kind: string, name: string, claimName: string) { const apiKind = kind === 'StatefulSet' ? 'statefulset' : ''; if (!apiKind) return Promise.reject(new Error('StatefulSet 데이터 볼륨만 삭제할 수 있습니다.')); return apiFetch(`${apiBaseUrl}/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(apiKind)}/${encodeURIComponent(name)}/pvcs/${encodeURIComponent(claimName)}`, { method: 'DELETE', headers: authHeaders() }).then(async (response) => { if (!response.ok) { const body = await response.json().catch(() => null); const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message; throw new Error(message || '데이터 볼륨 삭제 요청이 실패했습니다.'); } return response.json().catch(() => null); }); } function loadComponentBuildEnv(componentId: string) { return apiFetch(`${apiBaseUrl}/apps/components/${encodeURIComponent(componentId)}/build-env`, { headers: authHeaders() }).then(async (response) => { if (!response.ok) { const body = await response.json().catch(() => null); const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message; throw new Error(message || '빌드 환경변수 조회 요청이 실패했습니다.'); } return response.json() as Promise<{ variables: BuildEnvVariable[] }>; }); } function saveComponentBuildEnv(componentId: string, variables: BuildEnvVariableForm[]) { return apiFetch(`${apiBaseUrl}/apps/components/${encodeURIComponent(componentId)}/build-env`, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ variables: variables .map((variable) => ({ key: variable.key.trim(), value: variable.value, isSecret: variable.isSecret })) .filter((variable) => variable.key) }) }).then(async (response) => { if (!response.ok) { const body = await response.json().catch(() => null); const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message; throw new Error(message || '빌드 환경변수 저장 요청이 실패했습니다.'); } return response.json() as Promise<{ variables: BuildEnvVariable[] }>; }); } function isRunningResource(resource: RuntimeResource) { if (!['Pod', 'Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind)) return false; return ['Running', 'Pending', 'ContainerCreating'].includes(resource.status) || !resource.ready.endsWith('/0'); } function isWorkloadResource(resource: RuntimeResource) { return ['Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind); } function isStoppedResource(resource: RuntimeResource) { return resource.ready.endsWith('/0'); } function runtimeStatusLabel(resource: RuntimeResource, stopped: boolean) { if (stopped) return '중지됨'; if (resource.status === 'Running') return '실행 중'; if (resource.status === 'Pending' || resource.status === 'ContainerCreating') return '준비 중'; if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return '오류'; return resource.status || '-'; } function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutral' | 'green' | 'amber' | 'red' | 'blue' { if (stopped) return 'neutral'; if (resource.status === 'Running') return 'green'; if (resource.status === 'Pending' || resource.status === 'ContainerCreating') return 'amber'; if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return 'red'; return 'blue'; } function isComponentRunning(component: ApiAppComponent, resources: RuntimeResource[]) { const name = component.name.toLowerCase(); return resources.some((resource) => { const resourceName = resource.name.toLowerCase(); return isRunningResource(resource) && (resourceName === name || resourceName.startsWith(`${name}-`)); }); } function componentPortForms(component: ApiAppComponent): ComponentPortForm[] { if (component.ports?.length) { return component.ports.map((port) => ({ name: port.name ?? '', port: port.port ? String(port.port) : '', targetPort: port.targetPort ? String(port.targetPort) : '', protocol: port.protocol ?? 'TCP' })); } if (component.port) { return [{ name: 'http', port: String(component.port), targetPort: String(component.targetPort ?? component.port), protocol: 'TCP' }]; } return [emptyComponentPort()]; } function formatComponentPorts(component: ApiAppComponent) { const ports = componentPortForms(component).filter((port) => port.port); if (ports.length === 0) return '-'; return ports.map((port) => `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}`).join(', '); } export function AppManagementPage() { const { toast } = useToast(); const [apiApps, setApiApps] = useState([]); const [isAppsLoading, setIsAppsLoading] = useState(true); const appsLoadingVisible = useMinimumLoading(isAppsLoading); const [selectedAppId, setSelectedAppId] = useState(() => getStoredSelectedAppId()); const [isCreateAppOpen, setIsCreateAppOpen] = useState(false); const [newAppKey, setNewAppKey] = useState(''); const [newAppName, setNewAppName] = useState(''); const [newAppNamespace, setNewAppNamespace] = useState(''); const [newAppDescription, setNewAppDescription] = useState(''); const [appCreateStatus, setAppCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle'); const [isComponentOpen, setIsComponentOpen] = useState(false); const [componentName, setComponentName] = useState(''); const [componentType, setComponentType] = useState('CONTAINER'); const [componentImageSource, setComponentImageSource] = useState('SOURCE'); const [componentExternalImage, setComponentExternalImage] = useState(''); const [componentWorkloadType, setComponentWorkloadType] = useState('Deployment'); const [componentReplicas, setComponentReplicas] = useState('1'); const [componentServiceType, setComponentServiceType] = useState('ClusterIP'); const [componentStorageEnabled, setComponentStorageEnabled] = useState(false); const [componentStorageSize, setComponentStorageSize] = useState(''); const [componentStorageMountPath, setComponentStorageMountPath] = useState(''); const [componentStorageRetain, setComponentStorageRetain] = useState(true); const [componentRepo, setComponentRepo] = useState(''); const [componentBranch, setComponentBranch] = useState('develop'); const [componentPort, setComponentPort] = useState(''); const [componentTargetPort, setComponentTargetPort] = useState(''); const [componentPorts, setComponentPorts] = useState([emptyComponentPort()]); const [componentSaveStatus, setComponentSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle'); const [editingComponent, setEditingComponent] = useState(null); const [buildEnvComponent, setBuildEnvComponent] = useState(null); const [buildEnvVariables, setBuildEnvVariables] = useState([emptyBuildEnvVariable()]); const [buildEnvStatus, setBuildEnvStatus] = useState<'idle' | 'loading' | 'saving' | 'error'>('idle'); const [deletingApp, setDeletingApp] = useState(null); const [appDeleteStatus, setAppDeleteStatus] = useState<'idle' | 'deleting' | 'error'>('idle'); const [appDeleteOptions, setAppDeleteOptions] = useState({ unlinkSources: true, deleteRuntimeSettings: true, scaleDownWorkloads: true, deleteKubernetesResources: false, deletePersistentVolumes: false }); const [deletingComponent, setDeletingComponent] = useState(null); const [runtimeResources, setRuntimeResources] = useState([]); const [workloadActionKey, setWorkloadActionKey] = useState(''); const [deletingWorkload, setDeletingWorkload] = useState(null); const [deleteServiceWithWorkload, setDeleteServiceWithWorkload] = useState(false); const [deletingVolumeWorkload, setDeletingVolumeWorkload] = useState(null); const [volumeClaims, setVolumeClaims] = useState([]); const [selectedVolumeClaimName, setSelectedVolumeClaimName] = useState(''); const [volumeDeleteConfirmText, setVolumeDeleteConfirmText] = useState(''); const [volumeDeleteBackupChecked, setVolumeDeleteBackupChecked] = useState(false); const [volumeDeleteStatus, setVolumeDeleteStatus] = useState<'idle' | 'loading' | 'deleting' | 'error'>('idle'); const selectedApp = apiApps.find((app) => app.id === selectedAppId) ?? apiApps[0]; const totalComponents = selectedApp?.components?.length ?? 0; const linkedComponents = selectedApp?.components?.filter((component) => component.repository).length ?? 0; const workloads = runtimeResources.filter(isWorkloadResource); const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName); const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : ''; const canDeleteVolume = Boolean( deletingVolumeWorkload && selectedVolumeClaimName && volumeDeleteBackupChecked && volumeDeleteConfirmText.trim() === volumeDeleteConfirmPhrase && volumeDeleteStatus !== 'loading' && volumeDeleteStatus !== 'deleting' ); useEffect(() => { setIsAppsLoading(true); loadApps() .then((apps) => { setApiApps(apps); setSelectedAppId((current) => { const storedAppId = getStoredSelectedAppId(); const preferredAppId = storedAppId || current; const nextAppId = apps.some((app) => app.id === preferredAppId) ? preferredAppId : apps[0]?.id || ''; if (nextAppId) setStoredSelectedAppId(nextAppId); return nextAppId; }); setIsAppsLoading(false); }) .catch(() => { setApiApps([]); setIsAppsLoading(false); }); }, []); const selectApp = (appId: string) => { setSelectedAppId(appId); setStoredSelectedAppId(appId); }; useEffect(() => { if (!selectedApp?.namespace) { setRuntimeResources([]); return; } let mounted = true; loadRuntimeResources(selectedApp.namespace) .then((result) => { if (mounted) setRuntimeResources(result.resources ?? []); }) .catch(() => { if (mounted) setRuntimeResources([]); }); return () => { mounted = false; }; }, [selectedApp?.namespace]); const refreshRuntimeResources = () => { if (!selectedApp?.namespace) { setRuntimeResources([]); return Promise.resolve(); } return loadRuntimeResources(selectedApp.namespace) .then((result) => setRuntimeResources(result.resources ?? [])) .catch(() => setRuntimeResources([])); }; const runWorkloadAction = (resource: RuntimeResource, action: 'restart' | 'start' | 'stop') => { if (!selectedApp?.namespace) return; const actionKey = `${action}-${resource.kind}-${resource.name}`; setWorkloadActionKey(actionKey); controlRuntimeWorkload(selectedApp.namespace, resource.kind, resource.name, action) .then(() => { toast({ type: 'success', title: action === 'restart' ? '워크로드 재시작' : action === 'start' ? '워크로드 시작' : '워크로드 중지', message: `${resource.name} 작업을 요청했습니다.` }); return refreshRuntimeResources(); }) .catch((error: Error) => { toast({ type: 'error', title: '워크로드 제어 실패', message: error.message }); }) .finally(() => setWorkloadActionKey('')); }; const requestDeleteWorkload = (resource: RuntimeResource) => { setDeletingWorkload(resource); setDeleteServiceWithWorkload(false); }; const confirmDeleteWorkload = () => { if (!selectedApp?.namespace || !deletingWorkload) return; const actionKey = `delete-${deletingWorkload.kind}-${deletingWorkload.name}`; setWorkloadActionKey(actionKey); deleteRuntimeWorkload(selectedApp.namespace, deletingWorkload.kind, deletingWorkload.name, deleteServiceWithWorkload) .then(() => { toast({ type: 'success', title: '워크로드 삭제', message: `${deletingWorkload.name} 삭제를 요청했습니다.` }); setDeletingWorkload(null); return refreshRuntimeResources(); }) .catch((error: Error) => { toast({ type: 'error', title: '워크로드 삭제 실패', message: error.message }); }) .finally(() => setWorkloadActionKey('')); }; const requestDeleteVolume = (resource: RuntimeResource) => { if (!selectedApp?.namespace) return; if (resource.kind !== 'StatefulSet') { toast({ type: 'error', title: '데이터 볼륨 삭제 불가', message: 'StatefulSet 워크로드만 데이터 볼륨을 삭제할 수 있습니다.' }); return; } if (!isStoppedResource(resource)) { toast({ type: 'warning', title: '워크로드 중지 필요', message: '데이터 볼륨 삭제 전 DB 워크로드를 먼저 중지하세요.' }); return; } setDeletingVolumeWorkload(resource); setVolumeClaims([]); setSelectedVolumeClaimName(''); setVolumeDeleteConfirmText(''); setVolumeDeleteBackupChecked(false); setVolumeDeleteStatus('loading'); loadWorkloadPersistentVolumeClaims(selectedApp.namespace, resource.kind, resource.name) .then((result) => { const claims = result.claims ?? []; setVolumeClaims(claims); setSelectedVolumeClaimName(claims[0]?.name ?? ''); setVolumeDeleteStatus('idle'); }) .catch((error: Error) => { setVolumeDeleteStatus('error'); toast({ type: 'error', title: '데이터 볼륨 조회 실패', message: error.message }); }); }; const closeVolumeDeleteModal = () => { setDeletingVolumeWorkload(null); setVolumeClaims([]); setSelectedVolumeClaimName(''); setVolumeDeleteConfirmText(''); setVolumeDeleteBackupChecked(false); setVolumeDeleteStatus('idle'); }; const confirmDeleteVolume = () => { if (!selectedApp?.namespace || !deletingVolumeWorkload || !selectedVolumeClaimName) return; setVolumeDeleteStatus('deleting'); deleteWorkloadPersistentVolumeClaim( selectedApp.namespace, deletingVolumeWorkload.kind, deletingVolumeWorkload.name, selectedVolumeClaimName ) .then(() => { toast({ type: 'success', title: '데이터 볼륨 삭제', message: `${selectedVolumeClaimName} 삭제를 요청했습니다.` }); closeVolumeDeleteModal(); return refreshRuntimeResources(); }) .catch((error: Error) => { setVolumeDeleteStatus('error'); toast({ type: 'error', title: '데이터 볼륨 삭제 실패', message: error.message }); }); }; const createApp = () => { if (!newAppKey.trim() || !newAppName.trim()) return; const namespace = newAppNamespace.trim(); setAppCreateStatus('saving'); apiFetch(`${apiBaseUrl}/apps`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ key: newAppKey, name: newAppName, namespace: namespace || undefined, description: newAppDescription || undefined }) }) .then((response) => { if (!response.ok) throw new Error('App create failed'); return response.json(); }) .then((result) => { if (!namespace) return result; return apiFetch(`${apiBaseUrl}/apps/runtime-namespaces`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ namespace }) }) .then(async (response) => { if (!response.ok) { const body = await response.json().catch(() => null); const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message; throw new Error(message || '네임스페이스 create failed'); } return response.json(); }) .then((namespaceResult) => { toast({ type: 'success', title: '네임스페이스 생성/확인', message: namespaceResult.created ? `${namespaceResult.namespace} 네임스페이스를 생성했습니다.` : `${namespaceResult.namespace} 네임스페이스가 이미 있습니다.` }); return result; }) .catch((namespaceError: Error) => { toast({ type: 'warning', title: '앱 등록 완료 / 네임스페이스 생성 실패', message: namespaceError.message }); return result; }); }) .then((result) => { if (result?.app?.id) grantAppPermissionToCurrentAdmin(result.app.id); return loadApps(); }) .then((apps) => { setApiApps(apps); setNewAppKey(''); setNewAppName(''); setNewAppNamespace(''); setNewAppDescription(''); setAppCreateStatus('idle'); setIsCreateAppOpen(false); toast({ type: 'success', title: '앱 등록', message: '앱 그룹이 등록되었습니다.' }); }) .catch(() => { setAppCreateStatus('error'); toast({ type: 'error', title: '앱 등록 실패', message: '중복 Key 또는 백엔드 상태를 확인하세요.' }); }); }; const getBuildTargetForType = (type: string) => { if (componentImageSource === 'EXTERNAL') return '외부 이미지'; if (componentImageSource === 'DATABASE') return 'DB 이미지'; const targets: Record = { ELECTRON: 'AppImage', CONTAINER: 'container-image', STATIC_WEB: 'web-image', DATABASE: 'database', MIGRATION: 'prisma/migrations' }; return targets[type] ?? 'container-image'; }; const isSourceBuildComponent = componentImageSource === 'SOURCE'; const isStorageWorkload = componentWorkloadType === 'StatefulSet' || componentType === 'DATABASE'; const componentPayload = () => { const normalizedPorts = componentPorts .map((item) => ({ name: item.name.trim() || undefined, port: item.port ? Number(item.port) : undefined, targetPort: item.targetPort ? Number(item.targetPort) : item.port ? Number(item.port) : undefined, protocol: item.protocol || 'TCP' })) .filter((item) => item.port); const primaryPort = normalizedPorts[0]; return { name: componentName.trim(), type: componentType, imageSource: componentImageSource, externalImage: componentImageSource !== 'SOURCE' ? componentExternalImage.trim() || undefined : undefined, workloadType: componentWorkloadType, replicas: componentReplicas ? Number(componentReplicas) : undefined, serviceType: componentServiceType, storageEnabled: componentStorageEnabled, storageSize: componentStorageEnabled ? componentStorageSize.trim() || undefined : undefined, storageMountPath: componentStorageEnabled ? componentStorageMountPath.trim() || undefined : undefined, storageRetain: componentStorageRetain, repository: isSourceBuildComponent ? componentRepo || undefined : undefined, branch: isSourceBuildComponent ? componentBranch || undefined : undefined, buildTarget: getBuildTargetForType(componentType), port: primaryPort?.port ?? (componentPort ? Number(componentPort) : undefined), targetPort: primaryPort?.targetPort ?? (componentTargetPort ? Number(componentTargetPort) : componentPort ? Number(componentPort) : undefined), ports: normalizedPorts.length ? normalizedPorts : undefined }; }; const createComponent = () => { if (!selectedApp?.id || !componentName.trim()) return; setComponentSaveStatus('saving'); apiFetch(`${apiBaseUrl}/apps/${selectedApp.id}/components`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(componentPayload()) }) .then((response) => { if (!response.ok) throw new Error('Component create failed'); return response.json(); }) .then(() => loadApps()) .then((apps) => { setApiApps(apps); setComponentName(''); setComponentType('CONTAINER'); setComponentImageSource('SOURCE'); setComponentExternalImage(''); setComponentWorkloadType('Deployment'); setComponentReplicas('1'); setComponentServiceType('ClusterIP'); setComponentStorageEnabled(false); setComponentStorageSize(''); setComponentStorageMountPath(''); setComponentStorageRetain(true); setComponentRepo(''); setComponentBranch('develop'); setComponentPort(''); setComponentTargetPort(''); setComponentPorts([emptyComponentPort()]); setComponentSaveStatus('idle'); setIsComponentOpen(false); toast({ type: 'success', title: '구성요소 추가', message: '앱 구성요소가 추가되었습니다.' }); }) .catch(() => { setComponentSaveStatus('error'); toast({ type: 'error', title: '구성요소 추가 실패', message: '입력값 또는 백엔드 상태를 확인하세요.' }); }); }; const openEditComponent = (component: ApiAppComponent) => { setEditingComponent(component); setComponentName(component.name); setComponentType(component.type); setComponentImageSource(component.imageSource ?? (component.type === 'DATABASE' ? 'DATABASE' : 'SOURCE')); setComponentExternalImage(component.externalImage ?? ''); setComponentWorkloadType(component.workloadType ?? (component.type === 'DATABASE' ? 'StatefulSet' : 'Deployment')); setComponentReplicas(component.replicas ? String(component.replicas) : '1'); setComponentServiceType(component.serviceType ?? 'ClusterIP'); setComponentStorageEnabled(Boolean(component.storageEnabled)); setComponentStorageSize(component.storageSize ?? ''); setComponentStorageMountPath(component.storageMountPath ?? ''); setComponentStorageRetain(component.storageRetain ?? true); setComponentRepo(component.repository ?? ''); setComponentBranch(component.branch ?? 'develop'); setComponentPort(component.port ? String(component.port) : ''); setComponentTargetPort(component.targetPort ? String(component.targetPort) : ''); setComponentPorts(componentPortForms(component)); setComponentSaveStatus('idle'); setIsComponentOpen(true); }; const closeComponentModal = () => { setIsComponentOpen(false); setEditingComponent(null); setComponentName(''); setComponentType('CONTAINER'); setComponentImageSource('SOURCE'); setComponentExternalImage(''); setComponentWorkloadType('Deployment'); setComponentReplicas('1'); setComponentServiceType('ClusterIP'); setComponentStorageEnabled(false); setComponentStorageSize(''); setComponentStorageMountPath(''); setComponentStorageRetain(true); setComponentRepo(''); setComponentBranch('develop'); setComponentPort(''); setComponentTargetPort(''); setComponentPorts([emptyComponentPort()]); setComponentSaveStatus('idle'); }; const updateComponent = () => { if (!editingComponent || !componentName.trim()) return; setComponentSaveStatus('saving'); apiFetch(`${apiBaseUrl}/apps/components/${editingComponent.id}`, { method: 'PATCH', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(componentPayload()) }) .then((response) => { if (!response.ok) throw new Error('Component update failed'); return response.json(); }) .then(() => loadApps()) .then((apps) => { setApiApps(apps); closeComponentModal(); toast({ type: 'success', title: '구성요소 수정', message: '앱 구성요소가 수정되었습니다.' }); }) .catch(() => { setComponentSaveStatus('error'); toast({ type: 'error', title: '구성요소 수정 실패', message: '입력값 또는 백엔드 API 상태를 확인하세요.' }); }); }; const openBuildEnvModal = (component: ApiAppComponent) => { setBuildEnvComponent(component); setBuildEnvStatus('loading'); const fallbackVariables = component.buildVariables?.length ? component.buildVariables.map((variable) => ({ id: variable.id, key: variable.key, value: variable.value, isSecret: variable.isSecret })) : [emptyBuildEnvVariable()]; setBuildEnvVariables(fallbackVariables); loadComponentBuildEnv(component.id) .then(({ variables }) => { setBuildEnvVariables(variables.length ? variables.map((variable) => ({ id: variable.id, key: variable.key, value: variable.value, isSecret: variable.isSecret })) : [emptyBuildEnvVariable()]); setBuildEnvStatus('idle'); }) .catch((error: Error) => { setBuildEnvStatus('error'); toast({ type: 'error', title: '빌드 환경변수 조회 실패', message: error.message }); }); }; const closeBuildEnvModal = () => { setBuildEnvComponent(null); setBuildEnvVariables([emptyBuildEnvVariable()]); setBuildEnvStatus('idle'); }; const saveBuildEnv = () => { if (!buildEnvComponent) return; setBuildEnvStatus('saving'); saveComponentBuildEnv(buildEnvComponent.id, buildEnvVariables) .then(({ variables }) => { setApiApps((current) => current.map((app) => ({ ...app, components: app.components?.map((component) => component.id === buildEnvComponent.id ? { ...component, buildVariables: variables } : component) }))); toast({ type: 'success', title: '빌드 환경변수 저장', message: '구성요소 빌드 환경변수를 저장했습니다.' }); closeBuildEnvModal(); }) .catch((error: Error) => { setBuildEnvStatus('error'); toast({ type: 'error', title: '빌드 환경변수 저장 실패', message: error.message }); }); }; const deleteComponent = () => { if (!deletingComponent) return; apiFetch(`${apiBaseUrl}/apps/components/${deletingComponent.id}`, { method: 'DELETE', headers: authHeaders() }) .then((response) => { if (!response.ok) throw new Error('Component delete failed'); return response.json().catch(() => null); }) .then(() => loadApps()) .then((apps) => { setApiApps(apps); setDeletingComponent(null); toast({ type: 'success', title: '구성요소 삭제', message: '앱 구성요소가 삭제되었습니다.' }); }) .catch(() => { toast({ type: 'error', title: '구성요소 삭제 실패', message: '참조 이력 또는 백엔드 API 상태를 확인하세요.' }); }); }; const deleteApp = () => { if (!deletingApp) return; setAppDeleteStatus('deleting'); apiFetch(`${apiBaseUrl}/apps/${deletingApp.id}/decommission`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(appDeleteOptions) }) .then((response) => { if (!response.ok) throw new Error('App delete failed'); return response.json().catch(() => null); }) .then(() => loadApps()) .then((apps) => { setApiApps(apps); setDeletingApp(null); setAppDeleteStatus('idle'); setSelectedAppId((current) => { if (current !== deletingApp.id) return current; const nextAppId = apps[0]?.id ?? ''; setStoredSelectedAppId(nextAppId); return nextAppId; }); toast({ type: 'success', title: '앱 삭제', message: '앱 그룹이 삭제되었습니다.' }); }) .catch(() => { setAppDeleteStatus('error'); toast({ type: 'error', title: '앱 삭제 실패', message: '참조 이력 또는 백엔드 API 상태를 확인하세요.' }); }); }; const updateAppDeleteOption = (key: keyof typeof appDeleteOptions, value: boolean) => { setAppDeleteOptions((current) => ({ ...current, [key]: value, ...(key === 'deleteKubernetesResources' && !value ? { deletePersistentVolumes: false } : {}) })); }; return ( <> setIsCreateAppOpen(true)}> 앱 등록 } />
{!isAppsLoading && apiApps.length === 0 ? ( ) : ( apiApps.map((app) => ( selectApp(app.id)} > )) )}
Key 앱 이름 네임스페이스 구성요소 상태 관리
등록된 앱 그룹이 없습니다.
{app.key} {app.name} {app.namespace || '-'} {app.components?.length ?? 0} {formatStatus(app.status)}
{selectedApp ? (
{[ ['네임스페이스', selectedApp.namespace], ['연결 소스', `${linkedComponents}개`], ['구성요소', `${totalComponents}개`], ['상태', formatStatus(selectedApp.status)] ].map(([label, value]) => (

{label}

{value}

))}

Key

{selectedApp.key}

설명

{selectedApp.description || '-'}

setIsComponentOpen(true)}> 추가 } />
{!isAppsLoading && (selectedApp.components ?? []).map((component) => { const running = isComponentRunning(component, runtimeResources); const imageSourceLabel = component.imageSource === 'EXTERNAL' ? '외부 이미지' : component.imageSource === 'DATABASE' ? 'DB 이미지' : '소스 빌드'; return ( ); })} {!isAppsLoading && (selectedApp.components ?? []).length === 0 ? ( ) : null}
구성요소 역할 유형 워크로드 포트 소스 관리
{component.name} {running ? 실행 중 : null} {imageSourceLabel} {component.type} {component.workloadType ?? '-'} {formatComponentPorts(component)} {component.repository || (component.imageSource === 'EXTERNAL' ? '외부 이미지' : '미연결')}
{!running ? ( ) :
등록된 구성요소가 없습니다.
총 {workloads.length}개} />
{!isAppsLoading && workloads.length === 0 ? ( ) : null} {workloads.map((resource) => { const stopped = isStoppedResource(resource); const actionBase = `${resource.kind}-${resource.name}`; const pending = workloadActionKey.endsWith(actionBase); return ( ); })}
이름 유형 준비 상태 이미지/상세 작업
제어할 워크로드가 없습니다.
{resource.name} {runtimeStatusLabel(resource, stopped)} {resource.kind} {resource.ready} {resource.detail || '-'}
{resource.kind === 'StatefulSet' ? ( ) : null}
) : !isAppsLoading ? ( 등록된 앱을 선택해주세요. ) : null}
{isCreateAppOpen ? (

앱 그룹 등록