import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Search, 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, listBuildJobs, listSources } from './application-api'; 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; 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 [loading, setLoading] = useState(false); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [page, setPage] = useState(1); 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 refresh = async () => { setLoading(true); try { const [nextSources, nextJobs] = await Promise.all([listSources(), listBuildJobs(selectedApp?.key)]); setSources(nextSources); setJobs(nextJobs); 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 || ''; const nextSource = filteredSources.find((source) => source.id === next); setBranch(nextSource?.branch || 'develop'); return next; }); } finally { setLoading(false); } }; useEffect(() => { void refresh(); }, [selectedAppId, selectedApp?.key]); useEffect(() => { if (!componentOptions.some((source) => source.id === selectedComponentId)) { const nextComponent = componentOptions[0]; setSelectedComponentId(nextComponent?.id || ''); setBranch(nextComponent?.branch || 'develop'); } }, [componentOptions, selectedComponentId]); const filteredJobs = useMemo(() => { const normalizedSearch = search.trim().toLowerCase(); return jobs .filter((job) => { const text = `${job.component.app.name} ${job.component.name} ${job.component.repository ?? ''} ${job.branch} ${job.commitSha ?? ''} ${resultArtifact(job)} ${job.status}`.toLowerCase(); const matchesSearch = normalizedSearch ? text.includes(normalizedSearch) : true; const matchesStatus = statusFilter ? job.status === statusFilter : true; return matchesSearch && matchesStatus; }) .sort((a, b) => jobTime(b) - jobTime(a)); }, [jobs, search, statusFilter]); const totalPages = Math.max(1, Math.ceil(filteredJobs.length / buildJobPageSize)); const currentPage = Math.min(page, totalPages); const pageStart = (currentPage - 1) * buildJobPageSize; const pagedJobs = filteredJobs.slice(pageStart, pageStart + buildJobPageSize); useEffect(() => { setPage(1); }, [search, statusFilter, selectedAppId]); const startBuild = async () => { if (!selectedComponent) return; try { 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}

빌드 작업 목록

setSearch(event.target.value)} />
{!loading && filteredJobs.length === 0 ? (
빌드 작업이 없습니다.
) : null} {pagedJobs.map((job, index) => (
No. {filteredJobs.length - pageStart - 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 ? '불러오는 중' : `총 ${filteredJobs.length}건 · ${currentPage} / ${totalPages}`}
setSelectedJob(null)} className="log-modal-shell" footer={} > {selectedJob?.logs ? (
            {selectedJob.logs}
          
) : (
작업을 선택하면 로그가 표시됩니다.
)}
); }