import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react'; import { Button, Card, CardHeader, 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, listMigrationsPage, 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 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 = 3; type MigrationRange = 'week' | 'month' | 'all'; const migrationRangeLabels: Record = { week: '일주일', month: '한달', all: '전체' }; function migrationRangeSince(range: MigrationRange) { if (range === 'all') return undefined; const date = new Date(); date.setDate(date.getDate() - (range === 'week' ? 7 : 30)); return date.toISOString(); } 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 [runningMigration, setRunningMigration] = useState(false); const [page, setPage] = useState(1); const [range, setRange] = useState('week'); const [totalJobs, setTotalJobs] = useState(0); 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 totalPages = Math.max(1, Math.ceil(totalJobs / migrationPageSize)); const currentPage = page; const pagedJobs = jobs; const refresh = async () => { setLoading(true); try { const [nextJobs, nextReleases] = await Promise.all([ listMigrationsPage({ appId: selectedAppId, page, limit: migrationPageSize, since: migrationRangeSince(range) }), listReleases(selectedApp?.key) ]); const succeededReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED'); setJobs(nextJobs.jobs); setTotalJobs(nextJobs.total); 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, selectedAppId, page, range]); 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]); useEffect(() => { setPage(1); }, [range]); const runMigration = async () => { if (runningMigration) return; setRunningMigration(true); 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 : '마이그레이션 실행에 실패했습니다.' }); } finally { setRunningMigration(false); } }; const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && envSecretName && command.trim()); return ( <> setConfirmOpen(true)}> 실행 )} />

선택 이미지

{selectedImage}

{(Object.keys(migrationRangeLabels) as MigrationRange[]).map((item) => ( ))} } />
{!loading && 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. {totalJobs - ((currentPage - 1) * migrationPageSize + index)} {selectedApp?.name ?? '앱'} {job.name}
릴리즈 {releaseVersion} 네임스페이스 {job.namespace} 이미지 {imageName} 시작 {formatDate(job.startedAt || job.createdAt)}

명령

{commandText}

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

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

앱

{selectedApp?.name ?? '-'}

릴리즈

{selectedRelease?.version ?? '-'}

네임스페이스

{namespace || '-'}

이미지

{selectedImage}

명령

{command}

); }