import { useEffect, useMemo, useState, type MouseEvent } from 'react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react'; import { Button, Card, CardHeader, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import type { BuildJob, SourceComponent } from '../../types/application'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls'; import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobsPage, listSources } from './application-api'; function blockPageAgentDestructiveClick(event: MouseEvent) { if (event.nativeEvent.isTrusted) return false; event.preventDefault(); event.stopPropagation(); return true; } function buildTargetForType(type?: string | null) { return { ELECTRON: 'AppImage', CONTAINER: 'container-image', STATIC_WEB: 'web-image', DATABASE: 'database', MIGRATION: 'prisma/migrations' }[type ?? ''] ?? 'container-image'; } function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) { if (!component) return '-'; const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch; if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`; if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`; return `localhost:5000/${component.name}:${suffix}`; } function statusLabel(status: string) { return { PENDING: '대기', RUNNING: '실행 중', SUCCEEDED: '성공', FAILED: '실패', CANCELED: '취소' }[status] ?? status; } function durationLabel(job: BuildJob) { if (job.status === 'PENDING') return '대기 중'; if (job.status === 'RUNNING') return '진행 중'; if (!job.startedAt || !job.finishedAt) return '-'; const startedAt = new Date(job.startedAt).getTime(); const finishedAt = new Date(job.finishedAt).getTime(); if (Number.isNaN(startedAt) || Number.isNaN(finishedAt) || finishedAt < startedAt) return '-'; const seconds = Math.max(1, Math.round((finishedAt - startedAt) / 1000)); const minutes = Math.floor(seconds / 60); const restSeconds = seconds % 60; return minutes ? `${minutes}분 ${restSeconds}초` : `${restSeconds}초`; } function resultArtifact(job: BuildJob) { return job.image || job.artifact || '-'; } function jobTime(job: BuildJob) { const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime(); return Number.isNaN(value) ? 0 : value; } const buildJobPageSize = 3; type BuildRange = 'week' | 'month' | 'all'; const buildRangeLabels: Record = { week: '일주일', month: '한달', all: '전체' }; function buildRangeSince(range: BuildRange) { if (range === 'all') return undefined; const date = new Date(); date.setDate(date.getDate() - (range === 'week' ? 7 : 30)); return date.toISOString(); } export function BuildManagementPage() { const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps(); const { toast } = useToast(); const [sources, setSources] = useState([]); const [jobs, setJobs] = useState([]); const [selectedComponentId, setSelectedComponentId] = useState(''); const [branch, setBranch] = useState('develop'); const [commitSha, setCommitSha] = useState(''); const [selectedJob, setSelectedJob] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [range, setRange] = useState('week'); const [totalJobs, setTotalJobs] = useState(0); const loadingVisible = useMinimumLoading(appsLoading || loading); const componentOptions = useMemo( () => { const appSources = selectedAppId ? sources.filter((source) => source.appId === selectedAppId) : sources; return appSources.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE'); }, [selectedAppId, sources] ); const selectedComponent = componentOptions.find((source) => source.id === selectedComponentId) ?? componentOptions[0]; const selectedTarget = selectedComponent?.buildTarget || buildTargetForType(selectedComponent?.type); const predicted = predictedArtifact(selectedComponent, branch, commitSha); const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING'); const totalPages = Math.max(1, Math.ceil(totalJobs / buildJobPageSize)); const currentPage = page; const pagedJobs = jobs; const loadJobs = async (nextPage = page, nextRange = range, appKey = selectedApp?.key) => { const result = await listBuildJobsPage({ appKey, page: nextPage, limit: buildJobPageSize, since: buildRangeSince(nextRange) }); setJobs(result.jobs); setTotalJobs(result.total); }; const refresh = async () => { setLoading(true); try { const [nextSources, nextJobs] = await Promise.all([ listSources(), listBuildJobsPage({ appKey: selectedApp?.key, page, limit: buildJobPageSize, since: buildRangeSince(range) }) ]); setSources(nextSources); setJobs(nextJobs.jobs); setTotalJobs(nextJobs.total); const filteredSources = (selectedAppId ? nextSources.filter((source) => source.appId === selectedAppId) : nextSources) .filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE'); setSelectedComponentId((prev) => { const valid = filteredSources.some((source) => source.id === prev); const next = valid ? prev : filteredSources[0]?.id || ''; setBranch('develop'); return next; }); } finally { setLoading(false); } }; useEffect(() => { void refresh(); }, [selectedAppId, selectedApp?.key, page, range]); useEffect(() => { if (!hasActiveJob) return; const timer = window.setInterval(() => { listBuildJobsPage({ appKey: selectedApp?.key, page, limit: buildJobPageSize, since: buildRangeSince(range) }) .then((nextJobs) => { setJobs(nextJobs.jobs); setTotalJobs(nextJobs.total); }) .catch((error: Error) => { toast({ type: 'error', message: `빌드 작업 갱신 실패: ${error.message}` }); }); }, 2000); return () => window.clearInterval(timer); }, [hasActiveJob, selectedApp?.key, toast, page, range]); useEffect(() => { if (!selectedJob) return; const nextSelectedJob = jobs.find((job) => job.id === selectedJob.id); if (nextSelectedJob && nextSelectedJob !== selectedJob) { setSelectedJob(nextSelectedJob); } }, [jobs, selectedJob]); useEffect(() => { if (!componentOptions.some((source) => source.id === selectedComponentId)) { const nextComponent = componentOptions[0]; setSelectedComponentId(nextComponent?.id || ''); setBranch('develop'); } }, [componentOptions, selectedComponentId]); useEffect(() => { setPage(1); }, [selectedAppId, range]); const startBuild = async () => { if (!selectedComponent) return; try { setConfirmOpen(false); const result = await createBuildJob({ componentId: selectedComponent.id, branch, commitSha }); toast({ type: 'success', message: '빌드 작업이 생성되었습니다.' }); setSelectedJob(result.buildJob); await refresh(); } catch (error) { toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 생성에 실패했습니다.' }); } }; const retryBuild = async (job: BuildJob) => { try { const result = await createBuildJob({ componentId: job.componentId, branch: job.branch, commitSha: job.commitSha ?? '' }); toast({ type: 'success', message: '빌드 작업이 다시 생성되었습니다.' }); setSelectedJob(result.buildJob); await refresh(); } catch (error) { toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 재실행에 실패했습니다.' }); } }; const stopJob = async (id: string) => { try { await cancelBuildJob(id); toast({ type: 'success', message: '빌드 중지를 요청했습니다.' }); await refresh(); } catch (error) { toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 중지에 실패했습니다.' }); } }; const removeJob = async (id: string) => { try { await deleteBuildJob(id); toast({ type: 'success', message: '빌드 작업이 삭제되었습니다.' }); await refresh(); } catch (error) { toast({ type: 'error', message: error instanceof Error ? error.message : '삭제에 실패했습니다.' }); } }; return ( <>
Repository {selectedComponent?.repository || '미연결'}
빌드 대상 {selectedTarget}
예상 결과물 {predicted}
{(Object.keys(buildRangeLabels) as BuildRange[]).map((item) => ( ))} } />
{!loading && jobs.length === 0 ? (
빌드 작업이 없습니다.
) : null} {pagedJobs.map((job, index) => (
No. {totalJobs - ((currentPage - 1) * buildJobPageSize + index)} {job.component.app.name} / {job.component.name}
Repository {job.component.repository || '-'} Branch {job.branch} Commit {job.commitSha || '-'} 대상 {job.component.buildTarget || buildTargetForType(job.component.type)}
{!isBuildDone(job.status) ? ( ) : ( )} {isBuildDone(job.status) ? ( ) : null}

결과물

{resultArtifact(job)}

시작 / 종료

{formatDate(job.startedAt || job.createdAt)} / {formatDate(job.finishedAt)}

소요시간

{durationLabel(job)}

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

{selectedComponent?.name ?? '-'} 구성요소 빌드를 실행합니다.

실행 후 빌드 작업이 생성되고, 작업 목록에서 진행 상태와 로그를 확인할 수 있습니다.

앱 {selectedApp?.name ?? '-'}
저장소 {selectedComponent?.repository || '-'}
브랜치 {branch}
커밋 {commitSha.trim() || '브랜치 최신'}
빌드 대상 {selectedTarget}
); }