import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { Requester } from '../../components/requester'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import type { AppRelease, DeploymentJob } from '../../types/application'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { AppSelector, formatDate, useApps } from './ApplicationControls'; import { createDeployment, deleteRelease, listDeploymentsPage, listReleases } from './application-api'; function statusLabel(status: string) { return { PENDING: '대기', RUNNING: '실행 중', SUCCEEDED: '성공', FAILED: '실패', CANCELED: '취소' }[status] ?? status; } function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' { if (status === 'SUCCEEDED' || status === '성공') return 'green'; if (status === 'RUNNING' || status === '실행 중') return 'blue'; if (status === 'PENDING' || status === '대기') return 'amber'; if (status === 'FAILED' || status === 'CANCELED' || status === '실패' || status === '취소') return 'red'; return 'neutral'; } function environmentLabel(environment: string) { return environment === 'prod' ? '운영' : environment; } type ImageTags = { images?: Record; artifacts?: Record; }; function releaseArtifacts(release?: AppRelease | null) { const imageTags = release?.imageTags as ImageTags | null | undefined; const entries = Object.entries(imageTags?.images ?? {}); if (entries.length) return entries; if (release?.artifact) return [[release.app.name, release.artifact]]; return []; } function firstArtifact(release: AppRelease) { const artifacts = releaseArtifacts(release); return artifacts[0]?.[1] ?? release.artifact ?? '-'; } function deploymentTime(deployment: DeploymentJob) { const value = new Date(deployment.createdAt || deployment.startedAt || deployment.finishedAt || 0).getTime(); return Number.isNaN(value) ? 0 : value; } const deploymentPageSize = 3; type DeploymentRange = 'week' | 'month' | 'all'; const deploymentRangeLabels: Record = { week: '일주일', month: '한달', all: '전체' }; function deploymentRangeSince(range: DeploymentRange) { if (range === 'all') return undefined; const date = new Date(); date.setDate(date.getDate() - (range === 'week' ? 7 : 30)); return date.toISOString(); } export function DeploymentManagementPage() { const { toast } = useToast(); const { apps, selectedApp, selectedAppId, loading: appsLoading, selectApp } = useApps(); const [deployments, setDeployments] = useState([]); const [releases, setReleases] = useState([]); const [releaseId, setReleaseId] = 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 [deployModalOpen, setDeployModalOpen] = useState(false); const [selectedDeployment, setSelectedDeployment] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [loading, setLoading] = useState(true); const [deploying, setDeploying] = useState(false); const [deletingRelease, setDeletingRelease] = useState(false); const [page, setPage] = useState(1); const [range, setRange] = useState('week'); const [totalDeployments, setTotalDeployments] = useState(0); const [deployedReleaseIdValues, setDeployedReleaseIdValues] = useState([]); const loadingVisible = useMinimumLoading(loading); const selectedRelease = releases.find((release) => release.id === releaseId); const filteredReleases = useMemo( () => releases.filter((release) => !selectedAppId || release.appId === selectedAppId), [releases, selectedAppId] ); const deployedReleaseIds = useMemo( () => new Set(deployedReleaseIdValues), [deployedReleaseIdValues] ); const deployableReleases = useMemo( () => filteredReleases.filter((release) => !deployedReleaseIds.has(release.id)), [filteredReleases, deployedReleaseIds] ); const filteredDeployments = useMemo( () => deployments, [deployments] ); const totalPages = Math.max(1, Math.ceil(totalDeployments / deploymentPageSize)); const currentPage = page; const pagedDeployments = filteredDeployments; const refresh = async () => { setLoading(true); try { const [nextDeployments, nextReleases] = await Promise.all([ listDeploymentsPage({ appKey: selectedApp?.key, page, limit: deploymentPageSize, since: deploymentRangeSince(range) }), listReleases(selectedApp?.key) ]); const deployableReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED'); setDeployments(nextDeployments.deployments); setTotalDeployments(nextDeployments.total); setDeployedReleaseIdValues(nextDeployments.deployedReleaseIds ?? []); setReleases(deployableReleases); } finally { setLoading(false); } }; useEffect(() => { void refresh(); }, [selectedApp?.key, selectedAppId, page, range]); useEffect(() => { setPage(1); }, [selectedAppId, range]); useEffect(() => { if (selectedRelease?.app?.namespace) setNamespace((prev) => prev || selectedRelease.app.namespace || ''); }, [selectedRelease?.id]); useEffect(() => { setReleaseId((prev) => deployableReleases.some((release) => release.id === prev) ? prev : deployableReleases[0]?.id || ''); setNamespace(deployableReleases[0]?.app?.namespace || ''); }, [deployableReleases]); const deployableCards = useMemo(() => deployableReleases.slice(0, 8), [deployableReleases]); const openDeployModal = (release?: AppRelease) => { if (release) { if (release.appId !== selectedAppId) selectApp(release.appId); setReleaseId(release.id); setNamespace(release.app.namespace || ''); } if (!release && !releaseId && !releases[0]?.app?.namespace) setNamespace(''); setReplicas('1'); setCpuRequest('100m'); setCpuLimit('500m'); setMemoryRequest('128Mi'); setMemoryLimit('256Mi'); setUpdateLauncherAppVersion(true); setDeployModalOpen(true); }; const deploy = async () => { if (deploying) return; 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 { const result = await createDeployment({ releaseId, environment: 'prod', namespace, updateLauncherAppVersion, resources: Object.keys(resources).length ? resources : undefined }); toast({ type: 'success', message: '배포 작업이 실행되었습니다.' }); setSelectedDeployment(result.deploymentJob); setDeployModalOpen(false); await refresh(); } catch (error) { toast({ type: 'error', message: error instanceof Error ? error.message : '배포 실행에 실패했습니다.' }); } finally { setDeploying(false); } }; const removeRelease = async () => { if (!deleteTarget || deletingRelease) return; setDeletingRelease(true); try { await deleteRelease(deleteTarget.id); toast({ type: 'success', message: '릴리즈를 삭제했습니다.' }); setDeleteTarget(null); await refresh(); } catch (error) { toast({ type: 'error', message: error instanceof Error ? error.message : '릴리즈 삭제에 실패했습니다.' }); } finally { setDeletingRelease(false); } }; return ( <> openDeployModal()}> 배포 실행 } />
{(Object.keys(deploymentRangeLabels) as DeploymentRange[]).map((item) => ( ))}
} />
{!loading && filteredDeployments.length === 0 ? (
배포 작업이 없습니다.
) : null} {pagedDeployments.map((deployment, index) => { const artifacts = releaseArtifacts(deployment.release); return (
No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + index)} {statusLabel(deployment.status)} {deployment.app.name} {deployment.release?.version || '빠른 배포'}
환경 {environmentLabel(deployment.environment)} 네임스페이스 {deployment.namespace} 시작 {formatDate(deployment.startedAt || deployment.createdAt)}

이미지

{artifacts.length ? ( artifacts.map(([name, artifact]) => (

{name}

{artifact}

)) ) : (

-

)}
); })}
{loading ? '불러오는 중' : `총 ${totalDeployments}건 · ${currentPage} / ${totalPages}`}
{deployableCards.length === 0 ? (
배포 가능한 릴리즈가 없습니다.
) : null} {deployableCards.map((release) => (
))}
{ if (!deploying) setDeployModalOpen(false); }} footer={<>}>
setNamespace(event.target.value)} placeholder="stdo-lms" />
setSelectedDeployment(null)} className="log-modal-shell" footer={} > {selectedDeployment?.logs ? (
            {selectedDeployment.logs}
          
) : (
배포 작업을 선택하면 로그가 표시됩니다.
)}
{ if (!deletingRelease) setDeleteTarget(null); }} footer={( <> )} >

{deleteTarget?.app.name ?? '-'} {' '} {deleteTarget?.version ?? '-'} {' '}릴리즈를 삭제합니다.

이미 성공 배포된 릴리즈는 서버에서 삭제가 차단됩니다.

); }