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, listBuildJobs, listReleases } 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; }; 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 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(); 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) { 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 }); } return [...map.values()]; } function releaseTime(release: AppRelease) { const value = new Date(release.createdAt).getTime(); return Number.isNaN(value) ? 0 : value; } const releasePageSize = 5; 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 [branch, setBranch] = useState('release'); const [selectedBuildIds, setSelectedBuildIds] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); 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( () => groupCandidateBuilds(candidateBuilds).filter((group) => !selectedAppId || group.appId === selectedAppId), [candidateBuilds, selectedAppId] ); const filteredReleases = useMemo( () => releases .filter((release) => !selectedAppId || release.appId === selectedAppId) .sort((a, b) => releaseTime(b) - releaseTime(a)), [releases, selectedAppId] ); 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([listReleases(), listBuildJobs()]); const succeededBuilds = nextBuilds.filter((job) => job.status === 'SUCCEEDED'); setReleases(nextReleases); setBuilds(succeededBuilds); } finally { setLoading(false); } }; useEffect(() => { void refresh(); }, []); useEffect(() => { setPage(1); }, [selectedAppId]); const totalPages = Math.max(1, Math.ceil(filteredReleases.length / releasePageSize)); const currentPage = Math.min(page, totalPages); const pageStart = (currentPage - 1) * releasePageSize; const pagedReleases = filteredReleases.slice(pageStart, pageStart + releasePageSize); 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); setSelectedBuildIds(latestBuildIdsByComponent(appBuilds)); 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, 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()}> 릴리즈 생성 } />
{filteredReleases.length === 0 ? (
생성된 릴리즈가 없습니다.
) : null} {pagedReleases.map((release) => { const artifacts = releaseArtifacts(release); 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}

)) ) : (

-

)}
); })}
{loading ? '불러오는 중' : `총 ${filteredReleases.length}건 · ${currentPage} / ${totalPages}`}
{candidateGroups.length === 0 ? (
릴리즈 후보가 없습니다.
) : null} {candidateGroups.map((group) => ( ))}
setReleaseModalOpen(false)} footer={<>}>
setVersion(event.target.value)} placeholder="버전 예: 0.1.1" /> setBranch(event.target.value)} placeholder="브랜치" />
{modalBuilds.length === 0 ? (
등록 가능한 새 빌드 결과가 없습니다.
) : null} {modalBuilds.map((job) => { const artifactVersion = buildVersion(job); return ( ); })}
); }