import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react'; import { Button, Card, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import type { AppRelease, RuntimeControlJob } from '../../types/application'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { StatusBadge, formatDate, useApps } from './ApplicationControls'; import { createMigration, listMigrations, listReleases } from './application-api'; type ImageTags = { images?: Record; artifacts?: Record; }; function imageMap(imageTags: unknown) { if (!imageTags || typeof imageTags !== 'object') return {}; const tags = imageTags as ImageTags; return tags.images ?? tags.artifacts ?? {}; } function imageEntries(imageTags: unknown) { return Object.entries(imageMap(imageTags)); } function selectedImageTag(release: AppRelease | undefined, imageName: string) { if (!release || !imageName) return '-'; return imageMap(release.imageTags)[imageName] ?? '-'; } function requestText(job: RuntimeControlJob, key: string, fallback = '-') { const value = job.request?.[key]; return typeof value === 'string' && value.trim() ? value : fallback; } function jobNumber(index: number, total: number) { return total - index; } function migrationTime(job: RuntimeControlJob) { const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime(); return Number.isNaN(value) ? 0 : value; } function sanitizeK8sName(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, ''); } function uniqueValues(values: string[]) { return [...new Set(values.filter(Boolean))]; } const migrationPageSize = 5; export function MigrationManagementPage() { const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps(); const { toast } = useToast(); const [jobs, setJobs] = useState([]); const [releases, setReleases] = useState([]); const [releaseId, setReleaseId] = useState(''); const [migrationComponentId, setMigrationComponentId] = useState(''); const [imageComponentName, setImageComponentName] = useState(''); const [namespace, setNamespace] = useState(''); const [command, setCommand] = useState('npx prisma migrate deploy --schema=prisma/schema.prisma'); const [envSecretName, setEnvSecretName] = useState(''); const [selectedJob, setSelectedJob] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const loadingVisible = useMinimumLoading(appsLoading || loading); const selectedRelease = releases.find((release) => release.id === releaseId); const migrationComponents = selectedApp?.components?.filter((component) => component.type === 'MIGRATION') ?? []; const images = imageEntries(selectedRelease?.imageTags); const namespaceOptions = useMemo( () => uniqueValues([selectedApp?.namespace || '', namespace]), [namespace, selectedApp?.namespace] ); const envSecretOptions = useMemo( () => uniqueValues([ ...images.map(([name]) => `${sanitizeK8sName(name)}-secret`), `${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`, envSecretName ]), [envSecretName, images, selectedApp?.key] ); const selectedImage = selectedImageTag(selectedRelease, imageComponentName); const sortedJobs = [...jobs].sort((a, b) => migrationTime(b) - migrationTime(a)); const totalPages = Math.max(1, Math.ceil(sortedJobs.length / migrationPageSize)); const currentPage = Math.min(page, totalPages); const pageStart = (currentPage - 1) * migrationPageSize; const pagedJobs = sortedJobs.slice(pageStart, pageStart + migrationPageSize); const refresh = async () => { setLoading(true); try { const [nextJobs, nextReleases] = await Promise.all([listMigrations(), listReleases(selectedApp?.key)]); const succeededReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED'); setJobs(nextJobs); setReleases(succeededReleases); setReleaseId((prev) => (succeededReleases.some((release) => release.id === prev) ? prev : succeededReleases[0]?.id || '')); setNamespace((prev) => prev || selectedApp?.namespace || ''); setMigrationComponentId((prev) => ( migrationComponents.some((component) => component.id === prev) ? prev : migrationComponents[0]?.id || '' )); setEnvSecretName((prev) => prev || `${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`); } finally { setLoading(false); } }; useEffect(() => { void refresh(); }, [selectedApp?.key, selectedApp?.namespace]); useEffect(() => { const nextImages = imageEntries(selectedRelease?.imageTags); setImageComponentName((prev) => (nextImages.some(([name]) => name === prev) ? prev : nextImages[0]?.[0] || '')); }, [selectedRelease?.id]); useEffect(() => { if (imageComponentName) setEnvSecretName(`${sanitizeK8sName(imageComponentName)}-secret`); }, [imageComponentName]); useEffect(() => { setNamespace(selectedApp?.namespace || ''); setMigrationComponentId(migrationComponents[0]?.id || ''); setEnvSecretName(`${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`); setPage(1); }, [selectedAppId]); const runMigration = async () => { try { setConfirmOpen(false); const result = await createMigration({ releaseId, migrationComponentId, imageComponentName, namespace, command, envSecretName }); toast({ type: 'success', message: '마이그레이션 작업이 실행되었습니다.' }); setSelectedJob(result.migrationJob); await refresh(); } catch (error) { toast({ type: 'error', message: error instanceof Error ? error.message : '마이그레이션 실행에 실패했습니다.' }); } }; const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && envSecretName && command.trim()); return ( <> setConfirmOpen(true)}> 실행 )} />

선택 이미지

{selectedImage}

마이그레이션 작업

마이그레이션 실행 이력과 로그입니다.

{jobs.length === 0 ? (
등록된 마이그레이션 작업이 없습니다.
) : pagedJobs.map((job, index) => { const releaseVersion = requestText(job, 'releaseVersion'); const imageName = requestText(job, 'imageComponentName', requestText(job, 'image')); const commandText = requestText(job, 'command', command); return (
No. {jobNumber(pageStart + index, sortedJobs.length)} {selectedApp?.name ?? '앱'} {job.name}
릴리즈 {releaseVersion} 네임스페이스 {job.namespace} 이미지 {imageName} 시작 {formatDate(job.startedAt || job.createdAt)}

명령

{commandText}

); })}
{loading ? '불러오는 중' : `총 ${sortedJobs.length}건 · ${currentPage} / ${totalPages}`}
setSelectedJob(null)} className="log-modal-shell"> {selectedJob?.logs ? (
            {selectedJob.logs}
          
) : (
작업을 선택하면 로그가 표시됩니다.
)}
setConfirmOpen(false)} footer={( <> )} >

선택한 릴리즈 기준으로 데이터베이스 변경 작업을 실행합니다. 적용 전 대상 정보를 확인하세요.

앱

{selectedApp?.name ?? '-'}

릴리즈

{selectedRelease?.version ?? '-'}

네임스페이스

{namespace || '-'}

이미지

{selectedImage}

명령

{command}

); }