import { useEffect, useMemo, useState } from 'react'; import { Plus, RotateCcw, Square, Trash2 } from 'lucide-react'; import { Badge, Button, Card, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import { formatDateTime } from '../../lib/date-format'; import { getStoredSelectedAppId, getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedAppId, setStoredSelectedComponentId } from '../../lib/selected-app'; import { statusDotToneClass } from '../../lib/status-dot'; import type { ApiApp, ApiAppComponent, RuntimeEnvProfile, RuntimeEnvVariable, RuntimeIngressRule, RuntimeResource } from '../../types/runtime'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { applyRuntimeProfile, createRuntimeIngressRule, createRuntimeProfile, createRuntimeVariable, deleteRuntimeIngressRule, deleteRuntimeVariable, loadApps, loadRuntimeProfiles, loadRuntimeResources, restartRuntimeWorkload, scaleRuntimeWorkload } from './runtime-api'; function environmentLabel(value?: string) { return { dev: '개발', stage: '검수', prod: '운영' }[value ?? ''] ?? value ?? '-'; } function formatDate(value?: string | null) { return formatDateTime(value); } function getWorkloadKind(kind: string) { if (kind === 'Deployment') return 'deployment'; if (kind === 'StatefulSet') return 'statefulset'; if (kind === 'DaemonSet') return 'daemonset'; return ''; } function isStopped(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 (resource.status === 'Error' || resource.status === 'CrashLoopBackOff' || resource.status === 'ImagePullBackOff' || resource.status === 'Failed') 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 (resource.status === 'Error' || resource.status === 'CrashLoopBackOff' || resource.status === 'ImagePullBackOff' || resource.status === 'Failed') return 'red'; return 'blue'; } function preferredComponentId(appId: string, components: ApiAppComponent[]) { const storedComponentId = getStoredSelectedComponentId(appId); return components.some((component) => component.id === storedComponentId) ? storedComponentId : components[0]?.id ?? ''; } export function RuntimeConfigPage() { const { toast } = useToast(); const [apps, setApps] = useState([]); const [appId, setAppId] = useState(''); const [profiles, setProfiles] = useState([]); const [profileId, setProfileId] = useState(''); const [resources, setResources] = useState([]); const [resourceCount, setResourceCount] = useState(0); const [loading, setLoading] = useState(true); const [actionKey, setActionKey] = useState(''); const [error, setError] = useState(''); const [profileModalOpen, setProfileModalOpen] = useState(false); const [deleteVariableTarget, setDeleteVariableTarget] = useState(null); const [deleteIngressTarget, setDeleteIngressTarget] = useState(null); const loadingVisible = useMinimumLoading(loading); const [profileName, setProfileName] = useState(''); const [profileEnvironment, setProfileEnvironment] = useState('dev'); const [profileNamespace, setProfileNamespace] = useState(''); const [variableComponentId, setVariableComponentId] = useState(''); const [variableKey, setVariableKey] = useState(''); const [variableValue, setVariableValue] = useState(''); const [variableIsSecret, setVariableIsSecret] = useState(false); const [ingressComponentId, setIngressComponentId] = useState(''); const [ingressHost, setIngressHost] = useState(''); const [ingressPath, setIngressPath] = useState('/'); const [ingressPort, setIngressPort] = useState(''); const [ingressStripPrefix, setIngressStripPrefix] = useState(false); const selectedApp = apps.find((app) => app.id === appId); const selectedProfile = profiles.find((profile) => profile.id === profileId); const components = selectedApp?.components ?? []; const variables = selectedProfile?.variables ?? []; const ingressRules = selectedProfile?.ingressRules ?? []; const namespace = selectedProfile?.namespace || selectedApp?.namespace || profileNamespace || ''; const workloads = resources.filter((resource) => ['Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind)); const envCount = variables.filter((variable) => !variable.isSecret).length; const secretCount = variables.filter((variable) => variable.isSecret).length; const selectedProfileLabel = selectedProfile ? `${selectedApp?.name ?? '앱'}${environmentLabel(selectedProfile.environment)} / ${environmentLabel(selectedProfile.environment)}` : '프로필 없음'; const namespaceOptions = useMemo(() => [...new Set(apps.map((app) => app.namespace).filter(Boolean) as string[])], [apps]); const duplicateProfile = profiles.find((profile) => ( profile.environment === profileEnvironment && profile.namespace.trim() === profileNamespace.trim() )); const refreshResources = (targetNamespace = namespace) => { if (!targetNamespace.trim()) { setResourceCount(0); return; } loadRuntimeResources(targetNamespace) .then((data) => { setResources(data.resources); setResourceCount(data.resources.length); }) .catch(() => { setResources([]); setResourceCount(0); }); }; const applyProfiles = (nextProfiles: RuntimeEnvProfile[], preferredProfileId?: string) => { setProfiles(nextProfiles); setProfileId((current) => { const nextId = preferredProfileId || (nextProfiles.some((profile) => profile.id === current) ? current : nextProfiles[0]?.id ?? ''); const nextProfile = nextProfiles.find((profile) => profile.id === nextId); if (nextProfile) { setProfileNamespace(nextProfile.namespace); refreshResources(nextProfile.namespace); } return nextId; }); }; const refreshProfiles = (targetAppId = appId, preferredProfileId?: string) => { if (!targetAppId) return Promise.resolve(); return loadRuntimeProfiles(targetAppId).then((nextProfiles) => applyProfiles(nextProfiles, preferredProfileId)); }; useEffect(() => { setLoading(true); loadApps() .then((nextApps) => { const storedAppId = getStoredSelectedAppId(); const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? ''; const nextApp = nextApps.find((app) => app.id === nextAppId); const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []); setApps(nextApps); setAppId(nextAppId); setProfileNamespace(nextApp?.namespace ?? ''); setVariableComponentId(nextComponentId); setIngressComponentId(nextComponentId); if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId); setIngressPort(''); return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]); }) .then((nextProfiles) => applyProfiles(nextProfiles)) .catch((loadError: Error) => setError(loadError.message)) .finally(() => setLoading(false)); }, []); const changeApp = (nextAppId: string) => { const nextApp = apps.find((app) => app.id === nextAppId); const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []); setStoredSelectedAppId(nextAppId); setAppId(nextAppId); setProfileNamespace(nextApp?.namespace ?? ''); setVariableComponentId(nextComponentId); setIngressComponentId(nextComponentId); if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId); setIngressPort(''); refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message)); }; useEffect(() => { const storedComponentId = preferredComponentId(appId, components); if (!storedComponentId) return; if (variableComponentId !== storedComponentId) setVariableComponentId(storedComponentId); if (ingressComponentId !== storedComponentId) setIngressComponentId(storedComponentId); }, [appId, components, ingressComponentId, variableComponentId]); useEffect(() => { const handleSelectedComponentChange = (event: Event) => { const detail = (event as CustomEvent<{ appId?: string; componentId?: string }>).detail; if (detail?.appId !== appId || !detail.componentId) return; if (!components.some((component) => component.id === detail.componentId)) return; setVariableComponentId(detail.componentId); setIngressComponentId(detail.componentId); setIngressPort(''); }; window.addEventListener(selectedComponentChangedEventName, handleSelectedComponentChange); return () => window.removeEventListener(selectedComponentChangedEventName, handleSelectedComponentChange); }, [appId, components]); const addProfile = () => { if (!appId || !profileName.trim() || !profileNamespace.trim()) return; if (duplicateProfile) { const message = `이미 ${environmentLabel(profileEnvironment)} / ${profileNamespace.trim()} 실행 설정이 있습니다.`; setError(message); toast({ type: 'warning', title: '프로필 중복', message: '기존 프로필을 선택해 설정값을 수정하세요.' }); return; } createRuntimeProfile({ appId, name: profileName.trim(), environment: profileEnvironment, namespace: profileNamespace.trim() }) .then(({ profile }) => { setProfileName(''); setProfileModalOpen(false); refreshProfiles(appId, profile.id); }) .catch((createError: Error) => setError(createError.message)); }; const addVariable = () => { if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return; const profile = selectedProfile; const key = variableKey.trim(); setError(''); setActionKey(`variable-add-${profile.id}`); createRuntimeVariable(selectedProfile.id, { componentId: variableComponentId, key, value: variableValue.trim(), isSecret: variableIsSecret }) .then(() => applyRuntimeProfile(profile.id)) .then(() => { setVariableKey(''); setVariableValue(''); setVariableIsSecret(false); toast({ type: 'success', title: '설정값 등록', message: `${key} 설정값을 추가하고 클러스터 설정에 반영했습니다.` }); refreshResources(profile.namespace); refreshProfiles(appId, profile.id); }) .catch((createError: Error) => { setError(createError.message); toast({ type: 'error', title: '설정값 등록 실패', message: createError.message }); }) .finally(() => setActionKey('')); }; const removeVariable = (variableId: string) => { if (!selectedProfile) return; const profile = selectedProfile; setActionKey(`variable-delete-${variableId}`); deleteRuntimeVariable(profile.id, variableId) .then(() => applyRuntimeProfile(profile.id)) .then(() => { toast({ type: 'success', title: '설정값 삭제', message: '설정값을 삭제하고 클러스터 설정에 반영했습니다.' }); refreshResources(profile.namespace); refreshProfiles(appId, profile.id); }) .catch((deleteError: Error) => { setError(deleteError.message); toast({ type: 'error', title: '설정값 삭제 실패', message: deleteError.message }); }) .finally(() => setActionKey('')); }; const addIngressRule = () => { if (!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()) return; const profile = selectedProfile; const host = ingressHost.trim(); setActionKey(`ingress-add-${profile.id}`); createRuntimeIngressRule(profile.id, { componentId: ingressComponentId, host, path: ingressPath.trim() || '/', servicePort: Number(ingressPort), stripPrefix: ingressStripPrefix, tlsEnabled: false }) .then(() => applyRuntimeProfile(profile.id)) .then(() => { setIngressHost(''); setIngressPath('/'); setIngressStripPrefix(false); toast({ type: 'success', title: '접속 설정 등록', message: `${host} 접속 설정을 추가하고 클러스터 설정에 반영했습니다.` }); refreshResources(profile.namespace); refreshProfiles(appId, profile.id); }) .catch((createError: Error) => { setError(createError.message); toast({ type: 'error', title: '접속 설정 등록 실패', message: createError.message }); }) .finally(() => setActionKey('')); }; const removeIngressRule = (ruleId: string) => { if (!selectedProfile) return; const profile = selectedProfile; setActionKey(`ingress-delete-${ruleId}`); deleteRuntimeIngressRule(profile.id, ruleId) .then(() => applyRuntimeProfile(profile.id)) .then(() => { toast({ type: 'success', title: '접속 설정 삭제', message: '접속 설정을 삭제하고 클러스터 설정에 반영했습니다.' }); refreshResources(profile.namespace); refreshProfiles(appId, profile.id); }) .catch((deleteError: Error) => { setError(deleteError.message); toast({ type: 'error', title: '접속 설정 삭제 실패', message: deleteError.message }); }) .finally(() => setActionKey('')); }; const runWorkloadAction = (resource: RuntimeResource, action: 'restart' | 'start' | 'stop') => { const apiKind = getWorkloadKind(resource.kind); if (!apiKind || !namespace) return; const key = `${action}-${resource.kind}-${resource.name}`; setActionKey(key); setError(''); const request = action === 'restart' ? restartRuntimeWorkload(namespace, apiKind, resource.name) : scaleRuntimeWorkload(namespace, apiKind, resource.name, action === 'start' ? 1 : 0); request .then(() => { toast({ type: 'success', title: action === 'restart' ? '워크로드 재시작' : action === 'start' ? '워크로드 시작' : '워크로드 중지', message: `${resource.name} 작업을 요청했습니다.` }); refreshResources(namespace); }) .catch((workloadError: Error) => { setError(workloadError.message); toast({ type: 'error', title: '워크로드 제어 실패', message: workloadError.message }); }) .finally(() => setActionKey('')); }; const requestDeleteWorkload = () => { toast({ type: 'warning', title: '삭제 API 미구현', message: '워크로드 삭제는 백엔드 delete 엔드포인트 추가 후 연결할 수 있습니다.' }); }; return ( <> setProfileModalOpen(true)} disabled={!appId}> 프로필 생성 )} /> {error ?
{error}
: null}

앱 선택

설정값을 관리할 앱 그룹을 선택합니다.

{namespace || '-'} 구성요소 {components.length} 프로필 {profiles.length}

네임스페이스

{namespace || '-'}

구성요소

{components.length}개

환경변수

{envCount}개

시크릿

{secretCount}개

런타임 프로필

개발과 운영처럼 배포 환경별로 적용할 설정 묶음입니다.

총 {profiles.length}개
{profiles.length === 0 ? (
등록된 런타임 프로필이 없습니다.
) : profiles.map((profile) => (
{ setProfileId(profile.id); setProfileNamespace(profile.namespace); refreshResources(profile.namespace); }} onKeyDown={(event) => { if (event.key !== 'Enter' && event.key !== ' ') return; setProfileId(profile.id); setProfileNamespace(profile.namespace); refreshResources(profile.namespace); }} >
{profile.name} {environmentLabel(profile.environment)} {profile.namespace}
환경변수 {profile.variables?.filter((variable) => !variable.isSecret).length ?? 0} 시크릿 {profile.variables?.filter((variable) => variable.isSecret).length ?? 0} 수정 {formatDate(profile.updatedAt ?? profile.createdAt)}
))}

설정값

구성요소별로 ConfigMap 또는 Secret에 반영될 항목입니다.

총 {variables.length}개
{variables.length === 0 ? ( ) : null} {variables.map((variable) => ( ))}
구성요소 Key Value 관리
등록된 설정값이 없습니다.
{variable.component?.name ?? '-'} {variable.key} {variable.isSecret ? '********' : variable.value}

설정값 추가

선택한 프로필에 구성요소별 환경변수 또는 시크릿을 추가합니다.

접속 설정

배포 시 생성할 서비스와 도메인 연결 정보를 관리합니다.

총 {ingressRules.length}개
{ingressRules.length === 0 ? ( ) : null} {ingressRules.map((rule) => ( ))}
구성요소 도메인 Path 포트 Prefix 관리
등록된 접속 설정이 없습니다.
{rule.component?.name ?? '-'} {rule.host} {rule.path} {rule.servicePort} {rule.stripPrefix ? '제거' : '유지'}

접속 설정 추가

선택한 프로필에 도메인 연결 정보를 추가합니다.

setProfileModalOpen(false)} footer={( <> )} >
{duplicateProfile ? (

이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.

) : null}
{ if (!actionKey) setDeleteVariableTarget(null); }} footer={( <> )} >

{deleteVariableTarget?.key ?? '-'} {' '}설정값을 삭제합니다.

삭제 후 클러스터 설정에 바로 반영됩니다.

{ if (!actionKey) setDeleteIngressTarget(null); }} footer={( <> )} >

{deleteIngressTarget?.host ?? '-'} {' '} {deleteIngressTarget?.path ?? ''} {' '}접속 설정을 삭제합니다.

삭제 후 클러스터 설정에 바로 반영됩니다.

); }