501 lines
22 KiB
TypeScript
501 lines
22 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
|
import { Requester } from '../../components/requester';
|
|
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, deleteRelease, listDeploymentsPage, 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';
|
|
}
|
|
|
|
function environmentLabel(environment: string) {
|
|
return environment === 'prod' ? '운영' : environment;
|
|
}
|
|
|
|
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 = 3;
|
|
type DeploymentRange = 'week' | 'month' | 'all';
|
|
|
|
const deploymentRangeLabels: Record<DeploymentRange, string> = {
|
|
week: '일주일',
|
|
month: '한달',
|
|
all: '전체'
|
|
};
|
|
|
|
function deploymentRangeSince(range: DeploymentRange) {
|
|
if (range === 'all') return undefined;
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
|
return date.toISOString();
|
|
}
|
|
|
|
export function DeploymentManagementPage() {
|
|
const { toast } = useToast();
|
|
const { apps, selectedApp, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
|
const [deployments, setDeployments] = useState<DeploymentJob[]>([]);
|
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
|
const [releaseId, setReleaseId] = useState('');
|
|
const [namespace, setNamespace] = useState('');
|
|
const [replicas, setReplicas] = useState('1');
|
|
const [cpuRequest, setCpuRequest] = useState('100m');
|
|
const [cpuLimit, setCpuLimit] = useState('500m');
|
|
const [memoryRequest, setMemoryRequest] = useState('128Mi');
|
|
const [memoryLimit, setMemoryLimit] = useState('256Mi');
|
|
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
|
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
|
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<AppRelease | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [deploying, setDeploying] = useState(false);
|
|
const [deletingRelease, setDeletingRelease] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [range, setRange] = useState<DeploymentRange>('week');
|
|
const [totalDeployments, setTotalDeployments] = useState(0);
|
|
const [deployedReleaseIdValues, setDeployedReleaseIdValues] = useState<string[]>([]);
|
|
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 deployedReleaseIds = useMemo(
|
|
() => new Set(deployedReleaseIdValues),
|
|
[deployedReleaseIdValues]
|
|
);
|
|
const deployableReleases = useMemo(
|
|
() => filteredReleases.filter((release) => !deployedReleaseIds.has(release.id)),
|
|
[filteredReleases, deployedReleaseIds]
|
|
);
|
|
const filteredDeployments = useMemo(
|
|
() => deployments,
|
|
[deployments]
|
|
);
|
|
const totalPages = Math.max(1, Math.ceil(totalDeployments / deploymentPageSize));
|
|
const currentPage = page;
|
|
const pagedDeployments = filteredDeployments;
|
|
|
|
const refresh = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [nextDeployments, nextReleases] = await Promise.all([
|
|
listDeploymentsPage({
|
|
appKey: selectedApp?.key,
|
|
page,
|
|
limit: deploymentPageSize,
|
|
since: deploymentRangeSince(range)
|
|
}),
|
|
listReleases(selectedApp?.key)
|
|
]);
|
|
const deployableReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
|
|
setDeployments(nextDeployments.deployments);
|
|
setTotalDeployments(nextDeployments.total);
|
|
setDeployedReleaseIdValues(nextDeployments.deployedReleaseIds ?? []);
|
|
setReleases(deployableReleases);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [selectedApp?.key, selectedAppId, page, range]);
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [selectedAppId, range]);
|
|
|
|
useEffect(() => {
|
|
if (selectedRelease?.app?.namespace) setNamespace((prev) => prev || selectedRelease.app.namespace || '');
|
|
}, [selectedRelease?.id]);
|
|
|
|
useEffect(() => {
|
|
setReleaseId((prev) => deployableReleases.some((release) => release.id === prev) ? prev : deployableReleases[0]?.id || '');
|
|
setNamespace(deployableReleases[0]?.app?.namespace || '');
|
|
}, [deployableReleases]);
|
|
|
|
const deployableCards = useMemo(() => deployableReleases.slice(0, 8), [deployableReleases]);
|
|
|
|
const openDeployModal = (release?: AppRelease) => {
|
|
if (release) {
|
|
if (release.appId !== selectedAppId) selectApp(release.appId);
|
|
setReleaseId(release.id);
|
|
setNamespace(release.app.namespace || '');
|
|
}
|
|
if (!release && !releaseId && !releases[0]?.app?.namespace) setNamespace('');
|
|
setReplicas('1');
|
|
setCpuRequest('100m');
|
|
setCpuLimit('500m');
|
|
setMemoryRequest('128Mi');
|
|
setMemoryLimit('256Mi');
|
|
setUpdateLauncherAppVersion(true);
|
|
setDeployModalOpen(true);
|
|
};
|
|
|
|
const deploy = async () => {
|
|
if (deploying) return;
|
|
setDeploying(true);
|
|
const requestedReplicas = Number.parseInt(replicas, 10);
|
|
const resources = {
|
|
...(Number.isFinite(requestedReplicas) && requestedReplicas > 0 ? { replicas: requestedReplicas } : {}),
|
|
...(cpuRequest.trim() ? { cpuRequest: cpuRequest.trim() } : {}),
|
|
...(cpuLimit.trim() ? { cpuLimit: cpuLimit.trim() } : {}),
|
|
...(memoryRequest.trim() ? { memoryRequest: memoryRequest.trim() } : {}),
|
|
...(memoryLimit.trim() ? { memoryLimit: memoryLimit.trim() } : {})
|
|
};
|
|
try {
|
|
const result = await createDeployment({
|
|
releaseId,
|
|
environment: 'prod',
|
|
namespace,
|
|
updateLauncherAppVersion,
|
|
resources: Object.keys(resources).length ? resources : undefined
|
|
});
|
|
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
|
setSelectedDeployment(result.deploymentJob);
|
|
setDeployModalOpen(false);
|
|
await refresh();
|
|
} catch (error) {
|
|
toast({ type: 'error', message: error instanceof Error ? error.message : '배포 실행에 실패했습니다.' });
|
|
} finally {
|
|
setDeploying(false);
|
|
}
|
|
};
|
|
|
|
const removeRelease = async () => {
|
|
if (!deleteTarget || deletingRelease) return;
|
|
setDeletingRelease(true);
|
|
try {
|
|
await deleteRelease(deleteTarget.id);
|
|
toast({ type: 'success', message: '릴리즈를 삭제했습니다.' });
|
|
setDeleteTarget(null);
|
|
await refresh();
|
|
} catch (error) {
|
|
toast({ type: 'error', message: error instanceof Error ? error.message : '릴리즈 삭제에 실패했습니다.' });
|
|
} finally {
|
|
setDeletingRelease(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="배포 관리"
|
|
description="생성된 릴리즈를 운영 환경에 배포하고 재배포와 롤백을 관리합니다."
|
|
actions={
|
|
<Button className="h-8 px-3 text-[13px]" disabled={deployableReleases.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 로그를 최신순으로 표시합니다."
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
{(Object.keys(deploymentRangeLabels) as DeploymentRange[]).map((item) => (
|
|
<Button
|
|
key={item}
|
|
variant={range === item ? 'default' : 'outline'}
|
|
className="h-8 px-3 text-[13px]"
|
|
onClick={() => setRange(item)}
|
|
>
|
|
{deploymentRangeLabels[item]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="space-y-3 p-4">
|
|
{!loading && 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. {totalDeployments - ((currentPage - 1) * deploymentPageSize + 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">{environmentLabel(deployment.environment)}</b></span>
|
|
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
|
<Requester item={deployment} label="요청자" />
|
|
<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 ? '불러오는 중' : `총 ${totalDeployments}건 · ${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) => (
|
|
<div
|
|
key={release.id}
|
|
className="rounded-md border border-border bg-background px-3 py-3 transition-colors hover:bg-muted/20"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => openDeployModal(release)}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate 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>
|
|
<Button
|
|
variant="ghost"
|
|
className="size-8 shrink-0 px-0 text-rose-300 hover:text-rose-200"
|
|
title="릴리즈 삭제"
|
|
aria-label={`${release.app.name} ${release.version} 릴리즈 삭제`}
|
|
onClick={() => setDeleteTarget(release)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Modal open={deployModalOpen} title="배포 실행" onClose={() => {
|
|
if (!deploying) setDeployModalOpen(false);
|
|
}} footer={<><Button variant="outline" disabled={deploying} onClick={() => setDeployModalOpen(false)}>취소</Button><Button disabled={deploying || !releaseId || !namespace} onClick={deploy}>{deploying ? '실행 중' : '실행'}</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} disabled={deploying} onChange={(event) => {
|
|
const release = releases.find((item) => item.id === event.target.value);
|
|
setReleaseId(event.target.value);
|
|
setNamespace(release?.app.namespace || '');
|
|
}}>
|
|
<option value="">릴리즈 선택</option>
|
|
{deployableReleases.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={namespace} disabled={deploying} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">Replicas</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
value={replicas}
|
|
disabled={deploying}
|
|
onChange={(event) => setReplicas(event.target.value)}
|
|
placeholder="1"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">CPU 요청</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
value={cpuRequest}
|
|
disabled={deploying}
|
|
onChange={(event) => setCpuRequest(event.target.value)}
|
|
placeholder="예: 100m"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">CPU 상한</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
value={cpuLimit}
|
|
disabled={deploying}
|
|
onChange={(event) => setCpuLimit(event.target.value)}
|
|
placeholder="예: 500m"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">메모리 요청</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
value={memoryRequest}
|
|
disabled={deploying}
|
|
onChange={(event) => setMemoryRequest(event.target.value)}
|
|
placeholder="예: 256Mi"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">메모리 상한</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
value={memoryLimit}
|
|
disabled={deploying}
|
|
onChange={(event) => setMemoryLimit(event.target.value)}
|
|
placeholder="예: 512Mi"
|
|
/>
|
|
</label>
|
|
<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}
|
|
disabled={deploying}
|
|
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>
|
|
|
|
<Modal
|
|
open={Boolean(deleteTarget)}
|
|
title="릴리즈 삭제"
|
|
onClose={() => {
|
|
if (!deletingRelease) setDeleteTarget(null);
|
|
}}
|
|
footer={(
|
|
<>
|
|
<Button variant="outline" disabled={deletingRelease} onClick={() => setDeleteTarget(null)}>취소</Button>
|
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deletingRelease} onClick={removeRelease}>
|
|
{deletingRelease ? '삭제 중' : '삭제'}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
<div className="space-y-3 text-sm">
|
|
<p>
|
|
<span className="font-semibold text-foreground">{deleteTarget?.app.name ?? '-'}</span>
|
|
{' '}
|
|
<span className="font-semibold text-foreground">{deleteTarget?.version ?? '-'}</span>
|
|
{' '}릴리즈를 삭제합니다.
|
|
</p>
|
|
<p className="text-muted-foreground">
|
|
이미 성공 배포된 릴리즈는 서버에서 삭제가 차단됩니다.
|
|
</p>
|
|
</div>
|
|
</Modal>
|
|
<LoadingModal open={loadingVisible} />
|
|
</>
|
|
);
|
|
}
|