init studio admin frontend
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user