448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
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<HTMLElement>) {
|
|
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<BuildRange, string> = {
|
|
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<SourceComponent[]>([]);
|
|
const [jobs, setJobs] = useState<BuildJob[]>([]);
|
|
const [selectedComponentId, setSelectedComponentId] = useState('');
|
|
const [branch, setBranch] = useState('develop');
|
|
const [commitSha, setCommitSha] = useState('');
|
|
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [range, setRange] = useState<BuildRange>('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 {
|
|
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 (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="빌드 관리"
|
|
description="develop 브랜치의 빌드 검증 결과를 만들고, 성공한 작업을 릴리즈 후보로 넘깁니다."
|
|
/>
|
|
|
|
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
|
|
|
<Card className="mb-4">
|
|
<CardHeader title="빌드 요청" description="기본적으로 develop 최신 커밋을 검증합니다. 특정 커밋을 고정하려면 Commit SHA를 입력합니다." />
|
|
<div className="grid gap-4 p-5 xl:grid-cols-[1.2fr_1fr]">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
|
|
<select
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={selectedComponent?.id ?? ''}
|
|
onChange={(event) => {
|
|
setSelectedComponentId(event.target.value);
|
|
setBranch('develop');
|
|
}}
|
|
>
|
|
{componentOptions.length === 0 ? <option value="">구성요소 없음</option> : null}
|
|
{componentOptions.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Branch</span>
|
|
<select
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={branch}
|
|
onChange={(event) => setBranch(event.target.value)}
|
|
>
|
|
<option value="develop">develop</option>
|
|
</select>
|
|
</label>
|
|
<label className="block md:col-span-2">
|
|
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
value={commitSha}
|
|
onChange={(event) => setCommitSha(event.target.value)}
|
|
placeholder="비워두면 브랜치 최신 커밋 기준"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-4 text-sm">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-muted-foreground">Repository</span>
|
|
<span className="truncate font-medium">{selectedComponent?.repository || '미연결'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-muted-foreground">빌드 대상</span>
|
|
<span className="truncate font-medium">{selectedTarget}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-muted-foreground">예상 결과물</span>
|
|
<span className="truncate font-medium">{predicted}</span>
|
|
</div>
|
|
<Button className="mt-1 h-8 px-3 text-[13px]" onClick={startBuild} disabled={!selectedComponent || appsLoading || loading}>
|
|
<Play className="size-4" />
|
|
빌드 실행
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader
|
|
title="빌드 작업 목록"
|
|
description="선택한 기간의 빌드 작업을 최신순으로 표시합니다."
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
{(Object.keys(buildRangeLabels) as BuildRange[]).map((item) => (
|
|
<Button
|
|
key={item}
|
|
variant={range === item ? 'default' : 'outline'}
|
|
className="h-8 px-3 text-[13px]"
|
|
onClick={() => setRange(item)}
|
|
>
|
|
{buildRangeLabels[item]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="min-h-[410px] space-y-3 p-4">
|
|
{!loading && jobs.length === 0 ? (
|
|
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
|
빌드 작업이 없습니다.
|
|
</div>
|
|
) : null}
|
|
{pagedJobs.map((job, index) => (
|
|
<div key={job.id} className="rounded-md border border-border bg-background/70 p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-xs font-medium text-muted-foreground">No. {totalJobs - ((currentPage - 1) * buildJobPageSize + index)}</span>
|
|
<StatusBadge status={statusLabel(job.status)} />
|
|
<span className="font-medium">{job.component.app.name}</span>
|
|
<span className="text-muted-foreground">/</span>
|
|
<span className="font-medium">{job.component.name}</span>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
|
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
|
<span className="text-muted-foreground">대상 <b className="text-foreground">{job.component.buildTarget || buildTargetForType(job.component.type)}</b></span>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 justify-end gap-2">
|
|
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
{!isBuildDone(job.status) ? (
|
|
<Button variant="outline" className="size-8 px-0" title="중지" aria-label="중지" onClick={() => stopJob(job.id)}>
|
|
<Square className="size-4" />
|
|
</Button>
|
|
) : (
|
|
<Button variant="outline" className="size-8 px-0" title="재실행" aria-label="재실행" onClick={() => retryBuild(job)}>
|
|
<RotateCcw className="size-4" />
|
|
</Button>
|
|
)}
|
|
{isBuildDone(job.status) ? (
|
|
<Button
|
|
variant="outline"
|
|
className="size-8 px-0"
|
|
title="삭제"
|
|
aria-label="삭제"
|
|
onClick={(event) => {
|
|
if (blockPageAgentDestructiveClick(event)) return;
|
|
removeJob(job.id);
|
|
}}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,1fr)_220px_120px]">
|
|
<div className="min-w-0">
|
|
<p className="text-muted-foreground">결과물</p>
|
|
<p className="mt-1 truncate font-mono font-medium">{resultArtifact(job)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">시작 / 종료</p>
|
|
<p className="mt-1 font-medium">{formatDate(job.startedAt || job.createdAt)} / {formatDate(job.finishedAt)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">소요시간</p>
|
|
<p className="mt-1 font-medium">{durationLabel(job)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
|
<span className="text-xs text-muted-foreground">
|
|
{loading ? '불러오는 중' : `총 ${totalJobs}건 · ${currentPage} / ${totalPages}`}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 px-3 text-[13px]"
|
|
disabled={currentPage <= 1}
|
|
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
이전
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 px-3 text-[13px]"
|
|
disabled={currentPage >= totalPages}
|
|
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
|
>
|
|
다음
|
|
<ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Modal
|
|
open={Boolean(selectedJob)}
|
|
title="빌드 로그"
|
|
onClose={() => setSelectedJob(null)}
|
|
className="log-modal-shell"
|
|
footer={<Button variant="outline" onClick={() => setSelectedJob(null)}>닫기</Button>}
|
|
>
|
|
{selectedJob?.logs ? (
|
|
<pre className="whitespace-pre-wrap bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
|
{selectedJob.logs}
|
|
</pre>
|
|
) : (
|
|
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
|
작업을 선택하면 로그가 표시됩니다.
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
<LoadingModal open={loadingVisible} />
|
|
</>
|
|
);
|
|
}
|