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; artifacts?: Record; 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(); 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(); 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(); 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(); 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 = { 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([]); const [builds, setBuilds] = useState([]); const [version, setVersion] = useState(''); const [releaseModalOpen, setReleaseModalOpen] = useState(false); const [selectedBuildIds, setSelectedBuildIds] = useState([]); const [openLogReleaseId, setOpenLogReleaseId] = useState(''); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [range, setRange] = useState('week'); const [totalReleases, setTotalReleases] = useState(0); const [usedBuildIdValues, setUsedBuildIdValues] = useState([]); const [usedReleaseValueValues, setUsedReleaseValueValues] = useState([]); 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 ( <> openReleaseModal()}> 릴리즈 생성 } />
{(Object.keys(releaseRangeLabels) as ReleaseRange[]).map((item) => ( ))}
} />
{filteredReleases.length === 0 ? (
생성된 릴리즈가 없습니다.
) : null} {pagedReleases.map((release) => { const artifacts = releaseArtifacts(release); const isLogOpen = openLogReleaseId === release.id; return (
{statusLabel(release.status)} {release.app.name} {release.version}
Branch {release.branch} Commit {release.commitSha || 'multiple'} Build {buildCount(release)}개 생성 {formatDate(release.createdAt)}

산출물

{artifacts.length ? ( artifacts.map((artifact) => (

{artifact}

)) ) : (

-

)}
{isLogOpen ? (
                      {release.logs}
                    
) : null}
); })}
{loading ? '불러오는 중' : `총 ${totalReleases}건 · ${currentPage} / ${totalPages}`}
{candidateGroups.length === 0 ? (
릴리즈 후보가 없습니다.
) : null} {candidateGroups.map((group) => ( ))}
setReleaseModalOpen(false)} footer={<>}>
setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
{modalBuilds.length === 0 ? (
등록 가능한 새 빌드 결과가 없습니다.
) : null} {modalBuilds.map((job) => { const artifactVersion = buildVersion(job); return ( ); })}
); }