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" />
+144 -9
View File
@@ -1,6 +1,6 @@
import { Copy, ExternalLink, Link2, RotateCcw } from 'lucide-react';
import { Copy, ExternalLink, FileText, Link2, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils';
@@ -8,6 +8,7 @@ import type { GiteaRepo } from '../../types/application';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
getLauncherSource,
getGiteaCommitDiff,
listGiteaCommits,
listLauncherVersions,
listPlatformRepos,
@@ -21,6 +22,7 @@ const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@lo
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? 'platform';
type SourceTarget = 'launcher-client' | 'launcher-backend';
type CommitRange = 'week' | 'month' | 'all';
type SourceForm = {
repository: string;
@@ -40,6 +42,14 @@ const targetLabels: Record<SourceTarget, { title: string; description: string; d
}
};
const commitRangeLabels: Record<CommitRange, string> = {
week: '일주일',
month: '한달',
all: '전체'
};
const commitPageSize = 5;
function repoFullName(repo: GiteaRepo) {
return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name);
}
@@ -113,6 +123,13 @@ function relativeTime(value?: string | null) {
return new Intl.DateTimeFormat('ko-KR', { month: '2-digit', day: '2-digit' }).format(new Date(value));
}
function commitRangeSince(range: CommitRange) {
if (range === 'all') return undefined;
const date = new Date();
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
return date.toISOString();
}
export function LauncherSourcePage() {
const { toast } = useToast();
const [activeTarget, setActiveTarget] = useState<SourceTarget>('launcher-client');
@@ -124,8 +141,15 @@ export function LauncherSourcePage() {
'launcher-backend': emptyForm()
});
const [commits, setCommits] = useState<GiteaCommit[]>([]);
const [commitRange, setCommitRange] = useState<CommitRange>('week');
const [commitPage, setCommitPage] = useState(1);
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
const [commitsLoading, setCommitsLoading] = useState(false);
const [commitsError, setCommitsError] = useState('');
const [diffTarget, setDiffTarget] = useState<GiteaCommit | null>(null);
const [diffContent, setDiffContent] = useState('');
const [diffLoading, setDiffLoading] = useState(false);
const [diffError, setDiffError] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const loadingVisible = useMinimumLoading(loading);
@@ -181,24 +205,37 @@ export function LauncherSourcePage() {
setRepos(platformRepos);
}, [platformRepos]);
useEffect(() => {
setCommitPage(1);
}, [activeForm.repository, activeTarget, commitRange]);
useEffect(() => {
const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg);
if (!repository) {
setCommits([]);
setCommitsError('');
setHasNextCommitPage(false);
return;
}
let canceled = false;
setCommitsLoading(true);
setCommitsError('');
listGiteaCommits(repository.owner, repository.repo, sourceBranch, 5)
listGiteaCommits(repository.owner, repository.repo, sourceBranch, {
page: commitPage,
limit: commitPageSize + 1,
since: commitRangeSince(commitRange)
})
.then((nextCommits) => {
if (!canceled) setCommits(nextCommits);
if (!canceled) {
setCommits(nextCommits.slice(0, commitPageSize));
setHasNextCommitPage(nextCommits.length > commitPageSize);
}
})
.catch((error: Error) => {
if (!canceled) {
setCommits([]);
setHasNextCommitPage(false);
setCommitsError(error.message);
}
})
@@ -209,7 +246,7 @@ export function LauncherSourcePage() {
return () => {
canceled = true;
};
}, [activeForm.repository, activeTargetMeta.defaultOrg, sourceBranch]);
}, [activeForm.repository, activeTargetMeta.defaultOrg, sourceBranch, commitPage, commitRange]);
const save = () => {
if (!activeForm.repository.trim()) return;
@@ -237,6 +274,23 @@ export function LauncherSourcePage() {
toast({ type: 'success', message: '복사되었습니다.' });
};
const openCommitDiff = (commit: GiteaCommit) => {
const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg);
if (!repository) {
toast({ type: 'error', title: 'Diff 조회 실패', message: '저장소가 연결되어 있지 않습니다.' });
return;
}
setDiffTarget(commit);
setDiffContent('');
setDiffError('');
setDiffLoading(true);
getGiteaCommitDiff(repository.owner, repository.repo, commit.sha)
.then((diff) => setDiffContent(diff))
.catch((error: Error) => setDiffError(error.message))
.finally(() => setDiffLoading(false));
};
return (
<>
<PageTitle
@@ -338,7 +392,24 @@ export function LauncherSourcePage() {
</div>
<Card className="mt-4 min-w-0 overflow-hidden">
<CardHeader title="최근 커밋" description={`${activeTargetMeta.title} 저장소의 develop 브랜치 기준 최근 5개 커밋입니다.`} />
<CardHeader
title="커밋 목록"
description={`${activeTargetMeta.title} 저장소의 develop 브랜치 기준 커밋 내역입니다.`}
action={
<div className="flex items-center gap-2">
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
<Button
key={range}
variant={commitRange === range ? 'default' : 'outline'}
className="h-8 px-3 text-[13px]"
onClick={() => setCommitRange(range)}
>
{commitRangeLabels[range]}
</Button>
))}
</div>
}
/>
<div className="overflow-x-auto">
<table className="min-w-[760px] w-full text-left text-sm">
<thead className="border-y border-border bg-muted/30 text-xs text-muted-foreground">
@@ -347,22 +418,23 @@ export function LauncherSourcePage() {
<th className="px-5 py-3 font-medium">SHA1</th>
<th className="px-5 py-3 font-medium">메시지</th>
<th className="px-5 py-3 font-medium">날짜</th>
<th className="w-16 px-5 py-3 text-right font-medium">상세</th>
</tr>
</thead>
<tbody>
{commitsLoading ? (
<tr>
<td colSpan={4} className="px-5 py-10 text-center text-sm text-muted-foreground">커밋을 불러오는 중입니다.</td>
<td colSpan={5} className="px-5 py-10 text-center text-sm text-muted-foreground">커밋을 불러오는 중입니다.</td>
</tr>
) : null}
{!commitsLoading && commitsError ? (
<tr>
<td colSpan={4} className="px-5 py-10 text-center text-sm text-muted-foreground">커밋 조회 실패: {commitsError}</td>
<td colSpan={5} className="px-5 py-10 text-center text-sm text-muted-foreground">커밋 조회 실패: {commitsError}</td>
</tr>
) : null}
{!commitsLoading && !commitsError && commits.length === 0 ? (
<tr>
<td colSpan={4} className="px-5 py-10 text-center text-sm text-muted-foreground">표시할 커밋이 없습니다.</td>
<td colSpan={5} className="px-5 py-10 text-center text-sm text-muted-foreground">표시할 커밋이 없습니다.</td>
</tr>
) : null}
{!commitsLoading && !commitsError ? commits.map((commit) => (
@@ -371,13 +443,76 @@ export function LauncherSourcePage() {
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
<td className="whitespace-nowrap px-5 py-3 text-right">
<Button
variant="outline"
className="size-8 px-0"
title="내역 상세"
aria-label="내역 상세"
onClick={() => openCommitDiff(commit)}
>
<FileText className="size-4" />
</Button>
</td>
</tr>
)) : null}
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
<span className="text-muted-foreground">페이지 {commitPage}</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="h-8 px-3 text-[13px]"
disabled={commitPage <= 1 || commitsLoading}
onClick={() => setCommitPage((page) => Math.max(1, page - 1))}
>
이전
</Button>
<Button
variant="outline"
className="h-8 px-3 text-[13px]"
disabled={!hasNextCommitPage || commitsLoading}
onClick={() => setCommitPage((page) => page + 1)}
>
다음
</Button>
</div>
</div>
</Card>
<Modal
open={Boolean(diffTarget)}
title="커밋 내역 상세"
onClose={() => setDiffTarget(null)}
className="max-w-5xl"
footer={<Button variant="outline" onClick={() => setDiffTarget(null)}>닫기</Button>}
>
{diffTarget ? (
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-mono text-xs text-muted-foreground">{diffTarget.sha.slice(0, 10)}</span>
<span className="font-medium">{commitMessage(diffTarget)}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{commitAuthor(diffTarget)} · {relativeTime(commitDate(diffTarget))}
</p>
</div>
{diffLoading ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted-foreground">Diff를 불러오는 중입니다.</div>
) : diffError ? (
<div className="rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">Diff 조회 실패: {diffError}</div>
) : (
<pre className="max-h-[58vh] overflow-auto rounded-md border border-border bg-background p-4 font-mono text-xs leading-5 text-muted-foreground">
{diffContent || '표시할 변경 내역이 없습니다.'}
</pre>
)}
</div>
) : null}
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
+28 -4
View File
@@ -204,18 +204,42 @@ export function listStudioRepos(limit = 50) {
.then((result) => result.repos);
}
export function listGiteaCommits(owner: string, repo: string, branch = 'develop', limit = 5) {
export function listGiteaCommits(
owner: string,
repo: string,
branch = 'develop',
options?: { page?: number; limit?: number; since?: string }
) {
const params = new URLSearchParams({
sha: branch,
limit: String(limit)
page: String(options?.page ?? 1),
limit: String(options?.limit ?? 5)
});
if (options?.since) params.set('since', options.since);
return apiRequest<{ commits: GiteaCommit[] }>(
`/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?${params.toString()}`
).then((result) => result.commits);
}
export function listLauncherBuildJobs() {
return apiRequest<LauncherBuildJob[]>('/launcher/builds');
export function getGiteaCommitDiff(owner: string, repo: string, sha: string) {
return apiRequest<{ diff: string }>(
`/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(sha)}/diff`
).then((result) => result.diff);
}
export function listLauncherBuildJobs(options?: {
target?: string;
page?: number;
limit?: number;
since?: string;
}) {
const params = new URLSearchParams();
if (options?.target) params.set('target', options.target);
if (options?.page) params.set('page', String(options.page));
if (options?.limit) params.set('limit', String(options.limit));
if (options?.since) params.set('since', options.since);
const query = params.toString() ? `?${params.toString()}` : '';
return apiRequest<LauncherBuildJob[]>(`/launcher/builds${query}`);
}
export function getLauncherBackendDeployment() {