init studio admin frontend

This commit is contained in:
2026-07-01 17:34:15 +09:00
commit c50bd2b941
64 changed files with 22452 additions and 0 deletions
@@ -0,0 +1,280 @@
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<string, string>;
artifacts?: Record<string, 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 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 }>();
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<AppRelease[]>([]);
const [builds, setBuilds] = useState<BuildJob[]>([]);
const [version, setVersion] = useState('');
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
const [branch, setBranch] = useState('release');
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const loadingVisible = useMinimumLoading(loading);
const candidateGroups = useMemo(
() => groupCandidateBuilds(builds).filter((group) => !selectedAppId || group.appId === selectedAppId),
[builds, selectedAppId]
);
const filteredReleases = useMemo(
() => releases
.filter((release) => !selectedAppId || release.appId === selectedAppId)
.sort((a, b) => releaseTime(b) - releaseTime(a)),
[releases, selectedAppId]
);
const modalBuilds = useMemo(
() => builds.filter((build) => !selectedAppId || build.component.app.id === selectedAppId),
[builds, 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);
setSelectedBuildIds(builds.filter((build) => build.component.app.id === nextAppId).map((build) => build.id));
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 (
<>
<PageTitle
eyebrow="어플리케이션"
title="릴리즈 관리"
description="브랜치, 커밋, 빌드 산출물을 묶어 환경별로 배포 가능한 릴리즈를 생성합니다."
actions={
<Button className="h-8 px-3 text-[13px]" disabled={builds.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="성공한 빌드 결과를 버전 단위로 묶은 배포 후보입니다." />
<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);
return (
<div key={release.id} className="rounded-md border border-border bg-background/70 p-4">
<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>
<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>
</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 ? '불러오는 중' : `총 ${filteredReleases.length}건 · ${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="성공한 빌드 작업 중 릴리즈에 포함 가능한 항목입니다." />
<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);
setSelectedBuildIds(builds.filter((build) => build.component.app.id === event.target.value).map((build) => build.id));
}}>
{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" />
<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">
{modalBuilds.map((job) => (
<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">
<span className="block font-medium">{job.component.name}</span>
<span className="block truncate text-xs text-muted-foreground">{job.image || job.artifact || job.commitSha}</span>
</span>
</label>
))}
</div>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}