Files
studio-admin-fe/src/pages/applications/ReleaseManagementPage.tsx
T
2026-07-07 15:32:56 +09:00

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} />
</>
);
}