paginate launcher source and build history
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-07 09:20:06 +09:00
parent 3930da8ef2
commit 448eadfa2b
3 changed files with 255 additions and 30 deletions
+83 -17
View File
@@ -47,8 +47,22 @@ function jobTime(job: LauncherBuildJob) {
return Number.isNaN(value) ? 0 : value;
}
const launcherBuildJobPageSize = 3;
const launcherBuildJobPageSize = 5;
type BuildTarget = 'launcher-client' | 'launcher-backend';
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();
}
function sanitizeObjectKeyPart(value: string) {
return value
@@ -89,6 +103,8 @@ export function LauncherBuildPage() {
const [confirmOpen, setConfirmOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [range, setRange] = useState<BuildRange>('week');
const [hasNextPage, setHasNextPage] = useState(false);
const loadingVisible = useMinimumLoading(loading);
const activeVersion = useMemo(() => versions.find((item) => item.isActive) ?? versions[0], [versions]);
@@ -100,19 +116,39 @@ export function LauncherBuildPage() {
const backendDockerfile = backendSource?.dockerfile || 'Dockerfile';
const uploadPath = launcherArtifactKey(version || activeVersion?.version || '0.0.0', branch, commitSha, artifactName);
const downloadUrl = r2PublicBaseUrl ? `${r2PublicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(uploadPath)}` : '백엔드 R2 설정 기준으로 생성됩니다.';
const sortedJobs = useMemo(() => [...jobs].filter((job) => job.target === target).sort((a, b) => jobTime(b) - jobTime(a)), [jobs, target]);
const totalPages = Math.max(1, Math.ceil(sortedJobs.length / launcherBuildJobPageSize));
const currentPage = Math.min(page, totalPages);
const pageStart = (currentPage - 1) * launcherBuildJobPageSize;
const pagedJobs = sortedJobs.slice(pageStart, pageStart + launcherBuildJobPageSize);
const currentPage = page;
const pagedJobs = jobs;
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => (
listLauncherBuildJobs({
target: nextTarget,
page: nextPage,
limit: launcherBuildJobPageSize + 1,
since: buildRangeSince(nextRange)
}).then((nextJobs) => {
setJobs(nextJobs.slice(0, launcherBuildJobPageSize));
setHasNextPage(nextJobs.length > launcherBuildJobPageSize);
})
);
const refresh = () => {
setLoading(true);
Promise.all([listLauncherVersions(), listLauncherBuildJobs(), getLauncherSource(), listLauncherBuildEnv()])
Promise.all([
listLauncherVersions(),
listLauncherBuildJobs({
target,
page,
limit: launcherBuildJobPageSize + 1,
since: buildRangeSince(range)
}),
getLauncherSource(),
listLauncherBuildEnv()
])
.then(([nextVersions, nextJobs, nextSource, nextBuildEnv]) => {
setVersions(nextVersions);
setJobs(nextJobs);
setJobs(nextJobs.slice(0, launcherBuildJobPageSize));
setHasNextPage(nextJobs.length > launcherBuildJobPageSize);
setSource(nextSource.source);
setBuildEnv(nextBuildEnv.variables ?? []);
const nextVersion = nextVersions.find((item) => item.isActive) ?? nextVersions[0];
@@ -130,6 +166,18 @@ export function LauncherBuildPage() {
refresh();
}, []);
useEffect(() => {
setPage(1);
}, [target, range]);
useEffect(() => {
loadBuildJobs().catch((error: Error) => {
setJobs([]);
setHasNextPage(false);
toast({ type: 'error', title: '빌드 작업 조회 실패', message: error.message });
});
}, [target, page, range]);
useEffect(() => {
if (!source) return;
if (target === 'launcher-backend') {
@@ -145,14 +193,13 @@ export function LauncherBuildPage() {
useEffect(() => {
if (!hasActiveJob) return;
const timer = window.setInterval(() => {
listLauncherBuildJobs()
.then((nextJobs) => setJobs(nextJobs))
loadBuildJobs()
.catch((error: Error) => {
toast({ type: 'error', title: '빌드 작업 갱신 실패', message: error.message });
});
}, 2000);
return () => window.clearInterval(timer);
}, [hasActiveJob, toast]);
}, [hasActiveJob, toast, target, page, range]);
useEffect(() => {
if (!selectedJob) return;
@@ -176,8 +223,10 @@ export function LauncherBuildPage() {
downloadUrl: isBackend ? undefined : r2PublicBaseUrl ? downloadUrl : undefined
})
.then(({ job }) => {
setJobs((prev) => [job, ...prev]);
setPage(1);
setRange('week');
setJobs((prev) => [job, ...prev].slice(0, launcherBuildJobPageSize));
setHasNextPage(jobs.length >= launcherBuildJobPageSize);
setSelectedJob(job);
toast({ type: 'success', title: '빌드 작업', message: '런처 빌드 작업이 생성되었습니다.' });
})
@@ -312,7 +361,24 @@ export function LauncherBuildPage() {
</Card>
<Card className="min-w-0 overflow-hidden">
<CardHeader title="빌드 작업 목록" description={isBackend ? '런처 백엔드 이미지 빌드 작업입니다.' : '생성된 런처 클라이언트 빌드 작업과 업로드 계획입니다.'} />
<CardHeader
title="빌드 작업 목록"
description={isBackend ? '런처 백엔드 이미지 빌드 작업입니다.' : '생성된 런처 클라이언트 빌드 작업과 업로드 계획입니다.'}
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="flex justify-end px-4 pt-4">
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
@@ -330,7 +396,7 @@ export function LauncherBuildPage() {
<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. {sortedJobs.length - (pageStart + index)}</span>
<span className="text-xs font-medium text-muted-foreground">No. {(currentPage - 1) * launcherBuildJobPageSize + index + 1}</span>
<Badge tone={statusTone(job.status)}>{statusText(job.status)}</Badge>
<span className="font-medium">{job.target}</span>
</div>
@@ -363,7 +429,7 @@ export function LauncherBuildPage() {
</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 ? '불러오는 중' : `총 ${sortedJobs.length}건 · ${currentPage} / ${totalPages}`}
{loading ? '불러오는 중' : `페이지 ${currentPage}`}
</span>
<div className="flex gap-2">
<Button
@@ -378,8 +444,8 @@ export function LauncherBuildPage() {
<Button
variant="outline"
className="h-8 px-3 text-[13px]"
disabled={currentPage >= totalPages}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={!hasNextPage}
onClick={() => setPage((prev) => prev + 1)}
>
다음
<ChevronRight className="size-4" />