improve app release build selection
This commit is contained in:
@@ -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 ? (
|
||||||
<label key={job.id} className="flex items-start gap-2 rounded-md px-2 py-2 text-sm hover:bg-muted/20">
|
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
|
||||||
<input type="checkbox" checked={selectedBuildIds.includes(job.id)} onChange={() => toggleBuild(job.id)} />
|
등록 가능한 새 빌드 결과가 없습니다.
|
||||||
<span className="min-w-0">
|
</div>
|
||||||
<span className="block font-medium">{job.component.name}</span>
|
) : null}
|
||||||
<span className="block truncate text-xs text-muted-foreground">{job.image || job.artifact || job.commitSha}</span>
|
{modalBuilds.map((job) => {
|
||||||
</span>
|
const artifactVersion = buildVersion(job);
|
||||||
</label>
|
return (
|
||||||
))}
|
<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)} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user