447 lines
19 KiB
TypeScript
447 lines
19 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
|
import { useToast } from '../../components/toast';
|
|
import { PageTitle } from '../../layout/AppLayout';
|
|
import type { AppRelease, BuildJob } from '../../types/application';
|
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
|
import { AppSelector, formatDate, useApps } from './ApplicationControls';
|
|
import { createRelease, listBuildJobsPage, listReleasesPage } from './application-api';
|
|
|
|
function statusLabel(status: string) {
|
|
return {
|
|
PENDING: '대기',
|
|
RUNNING: '실행 중',
|
|
SUCCEEDED: '성공',
|
|
FAILED: '실패',
|
|
CANCELED: '취소'
|
|
}[status] ?? status;
|
|
}
|
|
|
|
function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
|
if (status === 'SUCCEEDED' || status === '성공') return 'green';
|
|
if (status === 'RUNNING' || status === '실행 중') return 'blue';
|
|
if (status === 'PENDING' || status === '대기') return 'amber';
|
|
if (status === 'FAILED' || status === 'CANCELED' || status === '실패' || status === '취소') return 'red';
|
|
return 'neutral';
|
|
}
|
|
|
|
type ImageTags = {
|
|
images?: Record<string, string>;
|
|
artifacts?: Record<string, string>;
|
|
error?: string;
|
|
promotion?: {
|
|
sourceBuildJobIds?: string[];
|
|
};
|
|
};
|
|
|
|
function releaseArtifacts(release: AppRelease) {
|
|
const imageTags = release.imageTags as ImageTags | null | undefined;
|
|
const values = [
|
|
...Object.values(imageTags?.images ?? {}),
|
|
...Object.values(imageTags?.artifacts ?? {})
|
|
].filter(Boolean);
|
|
|
|
if (values.length) return values;
|
|
return release.artifact ? [release.artifact] : [];
|
|
}
|
|
|
|
function buildValue(build: BuildJob) {
|
|
return build.image || build.artifact || '';
|
|
}
|
|
|
|
function buildCommit(build: BuildJob) {
|
|
return build.commitSha?.trim() || '';
|
|
}
|
|
|
|
function isDevelopBuild(build: BuildJob) {
|
|
return build.branch === 'develop';
|
|
}
|
|
|
|
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 usedReleaseBuildIds(releases: AppRelease[]) {
|
|
const ids = new Set<string>();
|
|
for (const release of releases) {
|
|
const imageTags = release.imageTags as ImageTags | null | undefined;
|
|
for (const id of imageTags?.promotion?.sourceBuildJobIds ?? []) {
|
|
ids.add(id);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
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 latestCandidateBuilds(builds: BuildJob[]) {
|
|
const latest = new Map<string, BuildJob>();
|
|
for (const build of builds) {
|
|
const current = latest.get(build.componentId);
|
|
if (!current || buildTime(build) > buildTime(current)) {
|
|
latest.set(build.componentId, build);
|
|
}
|
|
}
|
|
return [...latest.values()].sort((a, b) => buildTime(b) - buildTime(a));
|
|
}
|
|
|
|
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) {
|
|
const imageTags = release.imageTags as ImageTags | null | undefined;
|
|
const count = Object.keys(imageTags?.images ?? {}).length + Object.keys(imageTags?.artifacts ?? {}).length;
|
|
return count || (release.artifact ? 1 : 0);
|
|
}
|
|
|
|
function groupCandidateBuilds(builds: BuildJob[]) {
|
|
const map = new Map<string, { appId: string; appName: string; count: number; latestAt: number }>();
|
|
for (const build of builds) {
|
|
const appId = build.component.app.id;
|
|
const current = map.get(appId);
|
|
map.set(appId, {
|
|
appId,
|
|
appName: build.component.app.name,
|
|
count: (current?.count ?? 0) + 1,
|
|
latestAt: Math.max(current?.latestAt ?? 0, buildTime(build))
|
|
});
|
|
}
|
|
return [...map.values()].sort((a, b) => b.latestAt - a.latestAt);
|
|
}
|
|
|
|
function releaseTime(release: AppRelease) {
|
|
const value = new Date(release.createdAt).getTime();
|
|
return Number.isNaN(value) ? 0 : value;
|
|
}
|
|
|
|
const releasePageSize = 3;
|
|
type ReleaseRange = 'week' | 'month' | 'all';
|
|
|
|
const releaseRangeLabels: Record<ReleaseRange, string> = {
|
|
week: '일주일',
|
|
month: '한달',
|
|
all: '전체'
|
|
};
|
|
|
|
function releaseRangeSince(range: ReleaseRange) {
|
|
if (range === 'all') return undefined;
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
|
return date.toISOString();
|
|
}
|
|
|
|
export function ReleaseManagementPage() {
|
|
const { toast } = useToast();
|
|
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
|
const [builds, setBuilds] = useState<BuildJob[]>([]);
|
|
const [version, setVersion] = useState('');
|
|
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
|
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
|
|
const [openLogReleaseId, setOpenLogReleaseId] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [range, setRange] = useState<ReleaseRange>('week');
|
|
const [totalReleases, setTotalReleases] = useState(0);
|
|
const [usedBuildIdValues, setUsedBuildIdValues] = useState<string[]>([]);
|
|
const [usedReleaseValueValues, setUsedReleaseValueValues] = useState<string[]>([]);
|
|
const loadingVisible = useMinimumLoading(loading);
|
|
const usedValues = useMemo(() => new Set(usedReleaseValueValues), [usedReleaseValueValues]);
|
|
const usedBuildIds = useMemo(() => new Set(usedBuildIdValues), [usedBuildIdValues]);
|
|
const candidateBuilds = useMemo(
|
|
() => builds.filter((build) => {
|
|
const value = buildValue(build);
|
|
return isDevelopBuild(build) && Boolean(buildCommit(build)) && !usedBuildIds.has(build.id) && (!value || !usedValues.has(value));
|
|
}),
|
|
[builds, usedBuildIds, usedValues]
|
|
);
|
|
|
|
const candidateGroups = useMemo(
|
|
() => groupCandidateBuilds(candidateBuilds).filter((group) => !selectedAppId || group.appId === selectedAppId),
|
|
[candidateBuilds, selectedAppId]
|
|
);
|
|
const filteredReleases = releases;
|
|
const modalBuilds = useMemo(
|
|
() => candidateBuilds
|
|
.filter((build) => !selectedAppId || build.component.app.id === selectedAppId)
|
|
.sort((a, b) => buildTime(b) - buildTime(a)),
|
|
[candidateBuilds, selectedAppId]
|
|
);
|
|
|
|
const refresh = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [nextReleases, nextBuilds] = await Promise.all([
|
|
listReleasesPage({
|
|
appKey: apps.find((app) => app.id === selectedAppId)?.key,
|
|
page,
|
|
limit: releasePageSize,
|
|
since: releaseRangeSince(range)
|
|
}),
|
|
listBuildJobsPage({
|
|
appKey: apps.find((app) => app.id === selectedAppId)?.key,
|
|
page: 1,
|
|
limit: 100,
|
|
since: releaseRangeSince(range),
|
|
status: 'SUCCEEDED'
|
|
})
|
|
]);
|
|
setReleases(nextReleases.releases);
|
|
setTotalReleases(nextReleases.total);
|
|
setUsedBuildIdValues(nextReleases.usedBuildIds ?? []);
|
|
setUsedReleaseValueValues(nextReleases.usedValues ?? []);
|
|
setBuilds(nextBuilds.jobs);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [selectedAppId, page, range, apps]);
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [selectedAppId, range]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalReleases / releasePageSize));
|
|
const currentPage = page;
|
|
const pagedReleases = filteredReleases;
|
|
|
|
const openReleaseModal = (appId?: string) => {
|
|
const nextAppId = appId || selectedAppId || candidateGroups[0]?.appId || '';
|
|
if (nextAppId && nextAppId !== selectedAppId) selectApp(nextAppId);
|
|
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === nextAppId);
|
|
const latestBuilds = latestCandidateBuilds(appBuilds);
|
|
setSelectedBuildIds(latestBuildIdsByComponent(latestBuilds));
|
|
setVersion((prev) => prev || newestBuildVersion(appBuilds));
|
|
setReleaseModalOpen(true);
|
|
};
|
|
|
|
const toggleBuild = (id: string) => {
|
|
setSelectedBuildIds((prev) => prev.includes(id) ? prev.filter((buildId) => buildId !== id) : [...prev, id]);
|
|
};
|
|
|
|
const submit = async () => {
|
|
if (!selectedAppId) return;
|
|
try {
|
|
await createRelease({ appId: selectedAppId, version, branch: 'release', buildJobIds: selectedBuildIds });
|
|
toast({ type: 'success', message: '릴리즈 생성을 시작했습니다.' });
|
|
setVersion('');
|
|
setSelectedBuildIds([]);
|
|
setReleaseModalOpen(false);
|
|
await refresh();
|
|
} catch (error) {
|
|
toast({ type: 'error', message: error instanceof Error ? error.message : '릴리즈 생성에 실패했습니다.' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="릴리즈 관리"
|
|
description="검증에 성공한 develop 빌드를 release로 승격한 뒤 release 기준으로 다시 빌드합니다."
|
|
actions={
|
|
<Button className="h-8 px-3 text-[13px]" disabled={candidateBuilds.length === 0} onClick={() => openReleaseModal()}>
|
|
<Plus className="size-4" />
|
|
릴리즈 생성
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
|
|
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader
|
|
title="릴리즈 목록"
|
|
description="release 브랜치에서 빌드된 산출물 기준의 배포 후보입니다."
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
{(Object.keys(releaseRangeLabels) as ReleaseRange[]).map((item) => (
|
|
<Button
|
|
key={item}
|
|
variant={range === item ? 'default' : 'outline'}
|
|
className="h-8 px-3 text-[13px]"
|
|
onClick={() => setRange(item)}
|
|
>
|
|
{releaseRangeLabels[item]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="space-y-3 p-4">
|
|
{filteredReleases.length === 0 ? (
|
|
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
|
생성된 릴리즈가 없습니다.
|
|
</div>
|
|
) : null}
|
|
{pagedReleases.map((release) => {
|
|
const artifacts = releaseArtifacts(release);
|
|
const isLogOpen = openLogReleaseId === release.id;
|
|
return (
|
|
<div key={release.id} className="rounded-md border border-border bg-background/70 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge tone={statusTone(release.status)}>{statusLabel(release.status)}</Badge>
|
|
<span className="text-base font-medium">{release.app.name}</span>
|
|
<span className="text-base font-medium">{release.version}</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 px-3 text-[13px]"
|
|
disabled={!release.logs}
|
|
onClick={() => setOpenLogReleaseId((prev) => prev === release.id ? '' : release.id)}
|
|
>
|
|
{isLogOpen ? '로그 닫기' : '로그'}
|
|
</Button>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
|
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
|
|
<span className="text-muted-foreground">생성 <b className="text-foreground">{formatDate(release.createdAt)}</b></span>
|
|
</div>
|
|
<div className="mt-3 rounded-md border border-border bg-card px-3 py-2">
|
|
<p className="text-xs text-muted-foreground">산출물</p>
|
|
{artifacts.length ? (
|
|
artifacts.map((artifact) => (
|
|
<p key={artifact} className="mt-1 truncate font-mono text-xs font-medium">{artifact}</p>
|
|
))
|
|
) : (
|
|
<p className="mt-1 text-xs text-muted-foreground">-</p>
|
|
)}
|
|
</div>
|
|
{isLogOpen ? (
|
|
<pre className="mt-3 max-h-72 overflow-auto rounded-md border border-border bg-black/70 p-3 font-mono text-xs leading-5 text-slate-100">
|
|
{release.logs}
|
|
</pre>
|
|
) : null}
|
|
</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 ? '불러오는 중' : `총 ${totalReleases}건 · ${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>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="릴리즈 후보" description="아직 릴리즈 처리에 사용되지 않은 develop 성공 빌드입니다." />
|
|
<div className="space-y-3 p-4">
|
|
{candidateGroups.length === 0 ? (
|
|
<div className="rounded-md border border-border px-3 py-4 text-center text-sm text-muted-foreground">
|
|
릴리즈 후보가 없습니다.
|
|
</div>
|
|
) : null}
|
|
{candidateGroups.map((group) => (
|
|
<button
|
|
key={group.appId}
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-md border border-border bg-background px-3 py-3 text-left transition-colors hover:bg-muted/20"
|
|
onClick={() => openReleaseModal(group.appId)}
|
|
>
|
|
<span className="text-sm font-medium">{group.appName}</span>
|
|
<Badge tone={group.count > 0 ? 'green' : 'neutral'}>{group.count}개</Badge>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Modal open={releaseModalOpen} title="릴리즈 생성" onClose={() => setReleaseModalOpen(false)} footer={<><Button variant="outline" onClick={() => setReleaseModalOpen(false)}>취소</Button><Button disabled={!version || selectedBuildIds.length === 0} onClick={submit}>생성</Button></>}>
|
|
<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) => {
|
|
selectApp(event.target.value);
|
|
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>)}
|
|
</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" />
|
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
|
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
|
|
</div>
|
|
<div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
|
|
{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">
|
|
<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="font-mono text-xs text-muted-foreground">{buildCommit(job).slice(0, 7)}</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>
|
|
</Modal>
|
|
<LoadingModal open={loadingVisible} />
|
|
</>
|
|
);
|
|
}
|