improve app release build selection

This commit is contained in:
2026-07-03 10:12:04 +09:00
parent a4462458de
commit 113bbd0abd
@@ -42,6 +42,44 @@ function releaseArtifacts(release: AppRelease) {
return release.artifact ? [release.artifact] : []; return release.artifact ? [release.artifact] : [];
} }
function buildValue(build: BuildJob) {
return build.image || build.artifact || '';
}
function buildTime(build: BuildJob) {
const value = new Date(build.finishedAt || build.createdAt).getTime();
return Number.isNaN(value) ? 0 : value;
}
function buildVersion(build: BuildJob) {
const artifact = build.artifact || '';
const match = artifact.match(/\/apps\/[^/]+\/[^/]+\/([^/]+)\//);
if (match?.[1]) return match[1];
const fileMatch = artifact.match(/[-_]v?(\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.-]+)?)/);
return fileMatch?.[1] ?? null;
}
function usedReleaseValues(releases: AppRelease[]) {
return new Set(releases.flatMap(releaseArtifacts));
}
function latestBuildIdsByComponent(builds: BuildJob[]) {
const map = new Map<string, BuildJob>();
for (const build of builds) {
const current = map.get(build.componentId);
if (!current || buildTime(build) > buildTime(current)) map.set(build.componentId, build);
}
return [...map.values()].map((build) => build.id);
}
function newestBuildVersion(builds: BuildJob[]) {
return builds
.slice()
.sort((a, b) => buildTime(b) - buildTime(a))
.map(buildVersion)
.find((value): value is string => Boolean(value)) ?? '';
}
function buildCount(release: AppRelease) { function buildCount(release: AppRelease) {
const imageTags = release.imageTags as ImageTags | null | undefined; const imageTags = release.imageTags as ImageTags | null | undefined;
const count = Object.keys(imageTags?.images ?? {}).length + Object.keys(imageTags?.artifacts ?? {}).length; const count = Object.keys(imageTags?.images ?? {}).length + Object.keys(imageTags?.artifacts ?? {}).length;
@@ -81,10 +119,18 @@ export function ReleaseManagementPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const loadingVisible = useMinimumLoading(loading); const loadingVisible = useMinimumLoading(loading);
const usedValues = useMemo(() => usedReleaseValues(releases), [releases]);
const candidateBuilds = useMemo(
() => builds.filter((build) => {
const value = buildValue(build);
return !value || !usedValues.has(value);
}),
[builds, usedValues]
);
const candidateGroups = useMemo( const candidateGroups = useMemo(
() => groupCandidateBuilds(builds).filter((group) => !selectedAppId || group.appId === selectedAppId), () => groupCandidateBuilds(candidateBuilds).filter((group) => !selectedAppId || group.appId === selectedAppId),
[builds, selectedAppId] [candidateBuilds, selectedAppId]
); );
const filteredReleases = useMemo( const filteredReleases = useMemo(
() => releases () => releases
@@ -93,8 +139,10 @@ export function ReleaseManagementPage() {
[releases, selectedAppId] [releases, selectedAppId]
); );
const modalBuilds = useMemo( const modalBuilds = useMemo(
() => builds.filter((build) => !selectedAppId || build.component.app.id === selectedAppId), () => candidateBuilds
[builds, selectedAppId] .filter((build) => !selectedAppId || build.component.app.id === selectedAppId)
.sort((a, b) => buildTime(b) - buildTime(a)),
[candidateBuilds, selectedAppId]
); );
const refresh = async () => { const refresh = async () => {
@@ -125,7 +173,9 @@ export function ReleaseManagementPage() {
const openReleaseModal = (appId?: string) => { const openReleaseModal = (appId?: string) => {
const nextAppId = appId || selectedAppId || candidateGroups[0]?.appId || ''; const nextAppId = appId || selectedAppId || candidateGroups[0]?.appId || '';
if (nextAppId && nextAppId !== selectedAppId) selectApp(nextAppId); if (nextAppId && nextAppId !== selectedAppId) selectApp(nextAppId);
setSelectedBuildIds(builds.filter((build) => build.component.app.id === nextAppId).map((build) => build.id)); const appBuilds = candidateBuilds.filter((build) => build.component.app.id === nextAppId);
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
setVersion((prev) => prev || newestBuildVersion(appBuilds));
setReleaseModalOpen(true); setReleaseModalOpen(true);
}; };
@@ -154,7 +204,7 @@ export function ReleaseManagementPage() {
title="릴리즈 관리" title="릴리즈 관리"
description="브랜치, 커밋, 빌드 산출물을 묶어 환경별로 배포 가능한 릴리즈를 생성합니다." description="브랜치, 커밋, 빌드 산출물을 묶어 환경별로 배포 가능한 릴리즈를 생성합니다."
actions={ actions={
<Button className="h-8 px-3 text-[13px]" disabled={builds.length === 0} onClick={() => openReleaseModal()}> <Button className="h-8 px-3 text-[13px]" disabled={candidateBuilds.length === 0} onClick={() => openReleaseModal()}>
<Plus className="size-4" /> <Plus className="size-4" />
릴리즈 생성 릴리즈 생성
</Button> </Button>
@@ -255,22 +305,37 @@ export function ReleaseManagementPage() {
<div className="space-y-3"> <div className="space-y-3">
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => { <select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => {
selectApp(event.target.value); selectApp(event.target.value);
setSelectedBuildIds(builds.filter((build) => build.component.app.id === event.target.value).map((build) => build.id)); const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
setVersion(newestBuildVersion(appBuilds));
}}> }}>
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)} {candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
</select> </select>
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" /> <input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={branch} onChange={(event) => setBranch(event.target.value)} placeholder="브랜치" /> <input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={branch} onChange={(event) => setBranch(event.target.value)} placeholder="브랜치" />
<div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2"> <div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
{modalBuilds.map((job) => ( {modalBuilds.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
등록 가능한 새 빌드 결과가 없습니다.
</div>
) : null}
{modalBuilds.map((job) => {
const artifactVersion = buildVersion(job);
return (
<label key={job.id} className="flex items-start gap-2 rounded-md px-2 py-2 text-sm hover:bg-muted/20"> <label key={job.id} className="flex items-start gap-2 rounded-md px-2 py-2 text-sm hover:bg-muted/20">
<input type="checkbox" checked={selectedBuildIds.includes(job.id)} onChange={() => toggleBuild(job.id)} /> <input type="checkbox" checked={selectedBuildIds.includes(job.id)} onChange={() => toggleBuild(job.id)} />
<span className="min-w-0"> <span className="min-w-0 flex-1">
<span className="block font-medium">{job.component.name}</span> <span className="flex flex-wrap items-center gap-2">
<span className="block truncate text-xs text-muted-foreground">{job.image || job.artifact || job.commitSha}</span> <span className="font-medium">{job.component.name}</span>
{artifactVersion ? <Badge tone="blue">v{artifactVersion}</Badge> : <Badge tone="neutral">버전 없음</Badge>}
<span className="text-xs text-muted-foreground">{job.branch}</span>
<span className="text-xs text-muted-foreground">{formatDate(job.finishedAt || job.createdAt)}</span>
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">{job.image || job.artifact || job.commitSha}</span>
</span> </span>
</label> </label>
))} );
})}
</div> </div>
</div> </div>
</Modal> </Modal>