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,305 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw } 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, DeploymentJob } from '../../types/application';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { AppSelector, formatDate, useApps } from './ApplicationControls';
import { createDeployment, listDeployments, 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 | null) {
const imageTags = release?.imageTags as ImageTags | null | undefined;
const entries = Object.entries(imageTags?.images ?? {});
if (entries.length) return entries;
if (release?.artifact) return [[release.app.name, release.artifact]];
return [];
}
function firstArtifact(release: AppRelease) {
const artifacts = releaseArtifacts(release);
return artifacts[0]?.[1] ?? release.artifact ?? '-';
}
function deploymentTime(deployment: DeploymentJob) {
const value = new Date(deployment.createdAt || deployment.startedAt || deployment.finishedAt || 0).getTime();
return Number.isNaN(value) ? 0 : value;
}
const deploymentPageSize = 5;
export function DeploymentManagementPage() {
const { toast } = useToast();
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
const [deployments, setDeployments] = useState<DeploymentJob[]>([]);
const [releases, setReleases] = useState<AppRelease[]>([]);
const [releaseId, setReleaseId] = useState('');
const [environment, setEnvironment] = useState('dev');
const [namespace, setNamespace] = useState('');
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
const [deployModalOpen, setDeployModalOpen] = useState(false);
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const loadingVisible = useMinimumLoading(loading);
const selectedRelease = releases.find((release) => release.id === releaseId);
const filteredReleases = useMemo(
() => releases.filter((release) => !selectedAppId || release.appId === selectedAppId),
[releases, selectedAppId]
);
const filteredDeployments = useMemo(
() => deployments
.filter((deployment) => !selectedAppId || deployment.appId === selectedAppId)
.sort((a, b) => deploymentTime(b) - deploymentTime(a)),
[deployments, selectedAppId]
);
const totalPages = Math.max(1, Math.ceil(filteredDeployments.length / deploymentPageSize));
const currentPage = Math.min(page, totalPages);
const pageStart = (currentPage - 1) * deploymentPageSize;
const pagedDeployments = filteredDeployments.slice(pageStart, pageStart + deploymentPageSize);
const refresh = async () => {
setLoading(true);
try {
const [nextDeployments, nextReleases] = await Promise.all([listDeployments(), listReleases()]);
const deployableReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
setDeployments(nextDeployments);
setReleases(deployableReleases);
} finally {
setLoading(false);
}
};
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
setPage(1);
}, [selectedAppId]);
useEffect(() => {
if (selectedRelease?.app?.namespace) setNamespace((prev) => prev || selectedRelease.app.namespace || '');
}, [selectedRelease?.id]);
useEffect(() => {
setReleaseId((prev) => filteredReleases.some((release) => release.id === prev) ? prev : filteredReleases[0]?.id || '');
setNamespace(filteredReleases[0]?.app?.namespace || '');
}, [filteredReleases]);
const deployableCards = useMemo(() => filteredReleases.slice(0, 8), [filteredReleases]);
const openDeployModal = (release?: AppRelease) => {
if (release) {
if (release.appId !== selectedAppId) selectApp(release.appId);
setReleaseId(release.id);
setNamespace(release.app.namespace || '');
}
setUpdateLauncherAppVersion(true);
setDeployModalOpen(true);
};
const deploy = async () => {
try {
const result = await createDeployment({ releaseId, environment, namespace, updateLauncherAppVersion });
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
setSelectedDeployment(result.deploymentJob);
setDeployModalOpen(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={filteredReleases.length === 0} onClick={() => openDeployModal()}>
<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="릴리즈별 배포 실행 결과와 rollout 로그를 확인합니다." />
<div className="space-y-3 p-4">
{filteredDeployments.length === 0 ? (
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
배포 작업이 없습니다.
</div>
) : null}
{pagedDeployments.map((deployment, index) => {
const artifacts = releaseArtifacts(deployment.release);
return (
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">No. {filteredDeployments.length - pageStart - index}</span>
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
<span className="text-base font-medium">{deployment.app.name}</span>
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span className="text-muted-foreground">환경 <b className="text-foreground">{deployment.environment}</b></span>
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
<span className="text-muted-foreground">요청자 <b className="text-foreground">{deployment.requestedBy || 'admin'}</b></span>
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
</div>
</div>
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedDeployment(deployment)}>
<MoreHorizontal className="size-4" />
</Button>
</div>
<div className="mt-3 rounded-md border border-border bg-card p-3">
<p className="text-xs text-muted-foreground">이미지</p>
{artifacts.length ? (
artifacts.map(([name, artifact]) => (
<div key={`${deployment.id}-${name}`} className="mt-2 flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{name}</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{artifact}</p>
</div>
<Button variant="outline" className="size-8 px-0" title="재배포" aria-label="재배포" onClick={() => deployment.release && openDeployModal(deployment.release)}>
<RotateCcw className="size-4" />
</Button>
</div>
))
) : (
<p className="mt-2 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 ? '불러오는 중' : `총 ${filteredDeployments.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">
{deployableCards.length === 0 ? (
<div className="rounded-md border border-border px-3 py-4 text-center text-sm text-muted-foreground">
배포 가능한 릴리즈가 없습니다.
</div>
) : null}
{deployableCards.map((release) => (
<button
type="button"
key={release.id}
className="w-full rounded-md border border-border bg-background px-3 py-3 text-left transition-colors hover:bg-muted/20"
onClick={() => openDeployModal(release)}
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{release.app.name}</span>
<Badge tone="green">{release.version}</Badge>
</div>
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">{firstArtifact(release)}</p>
</button>
))}
</div>
</Card>
</div>
<Modal open={deployModalOpen} title="배포 실행" onClose={() => setDeployModalOpen(false)} footer={<><Button variant="outline" onClick={() => setDeployModalOpen(false)}>취소</Button><Button disabled={!releaseId || !namespace} onClick={deploy}>실행</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={releaseId} onChange={(event) => {
const release = releases.find((item) => item.id === event.target.value);
setReleaseId(event.target.value);
setNamespace(release?.app.namespace || '');
}}>
<option value="">릴리즈 선택</option>
{filteredReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
</select>
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={environment} onChange={(event) => setEnvironment(event.target.value)} placeholder="dev / release / prod" />
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
<input
type="checkbox"
className="size-4 accent-primary"
checked={updateLauncherAppVersion}
onChange={(event) => setUpdateLauncherAppVersion(event.target.checked)}
/>
런처 앱 버전 업데이트
</label>
</div>
</Modal>
<Modal
open={Boolean(selectedDeployment)}
title="배포 로그"
onClose={() => setSelectedDeployment(null)}
className="log-modal-shell"
footer={<Button variant="outline" onClick={() => setSelectedDeployment(null)}>닫기</Button>}
>
{selectedDeployment?.logs ? (
<pre className="whitespace-pre-wrap bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
{selectedDeployment.logs}
</pre>
) : (
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
배포 작업을 선택하면 로그가 표시됩니다.
</div>
)}
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}