init studio admin frontend
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge } from '../../components/ui';
|
||||
import type { AppWithComponents, JobStatus } from '../../types/application';
|
||||
import { listApps } from './application-api';
|
||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
|
||||
export function useApps() {
|
||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||
const [selectedAppId, setSelectedAppId] = useState(() => getStoredSelectedAppId());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refreshApps = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextApps = await listApps();
|
||||
setApps(nextApps);
|
||||
const storedAppId = getStoredSelectedAppId();
|
||||
const validAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
|
||||
if (validAppId) {
|
||||
setSelectedAppId(validAppId);
|
||||
setStoredSelectedAppId(validAppId);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshApps();
|
||||
}, []);
|
||||
|
||||
const selectedApp = useMemo(
|
||||
() => apps.find((app) => app.id === selectedAppId) ?? apps[0] ?? null,
|
||||
[apps, selectedAppId]
|
||||
);
|
||||
|
||||
const selectApp = (appId: string) => {
|
||||
setSelectedAppId(appId);
|
||||
setStoredSelectedAppId(appId);
|
||||
};
|
||||
|
||||
return { apps, selectedApp, selectedAppId: selectedApp?.id ?? '', loading, refreshApps, selectApp };
|
||||
}
|
||||
|
||||
export function AppSelector({
|
||||
apps,
|
||||
value,
|
||||
onChange,
|
||||
loading
|
||||
}: {
|
||||
apps: AppWithComponents[];
|
||||
value: string;
|
||||
onChange: (appId: string) => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-col gap-2 rounded-lg border border-border bg-card px-5 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">앱 선택</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">선택한 앱 기준으로 소스, 빌드, 릴리즈, 배포 데이터를 봅니다.</p>
|
||||
</div>
|
||||
<select
|
||||
className="h-9 min-w-64 rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={value}
|
||||
disabled={loading || apps.length === 0}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{apps.length === 0 ? <option value="">등록된 앱을 선택해주세요</option> : null}
|
||||
{apps.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const normalized = status.toUpperCase();
|
||||
const label = {
|
||||
SUCCEEDED: '성공',
|
||||
ACTIVE: '활성',
|
||||
READY: '준비',
|
||||
RUNNING: '실행 중',
|
||||
PENDING: '대기',
|
||||
FAILED: '실패',
|
||||
DISABLED: '비활성',
|
||||
CANCELED: '취소'
|
||||
}[normalized] ?? status;
|
||||
|
||||
if (normalized === 'SUCCEEDED' || normalized === 'ACTIVE' || normalized === 'READY' || normalized === 'RUNNING' || status === '성공' || status === '활성' || status === '준비' || status === '실행 중') {
|
||||
return <Badge tone={normalized === 'RUNNING' || status === '실행 중' ? 'blue' : 'green'}>{label}</Badge>;
|
||||
}
|
||||
if (normalized === 'PENDING' || status === '대기') return <Badge tone="amber">{label}</Badge>;
|
||||
if (normalized === 'FAILED' || normalized === 'DISABLED' || normalized === 'CANCELED' || status === '실패' || status === '비활성' || status === '취소') return <Badge tone="red">{label}</Badge>;
|
||||
return <Badge>{label}</Badge>;
|
||||
}
|
||||
|
||||
export function isBuildDone(status: JobStatus) {
|
||||
return !['PENDING', 'RUNNING'].includes(status);
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
export function EmptyState({ children }: { children: string }) {
|
||||
return <div className="flex min-h-40 items-center justify-center text-sm text-muted-foreground">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Search, Square, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { BuildJob, SourceComponent } from '../../types/application';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
||||
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobs, listSources } from './application-api';
|
||||
|
||||
function buildTargetForType(type?: string | null) {
|
||||
return {
|
||||
ELECTRON: 'AppImage',
|
||||
CONTAINER: 'container-image',
|
||||
STATIC_WEB: 'web-image',
|
||||
DATABASE: 'database',
|
||||
MIGRATION: 'prisma/migrations'
|
||||
}[type ?? ''] ?? 'container-image';
|
||||
}
|
||||
|
||||
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
||||
if (!component) return '-';
|
||||
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
|
||||
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
||||
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
||||
return `localhost:5000/${component.name}:${suffix}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return {
|
||||
PENDING: '대기',
|
||||
RUNNING: '실행 중',
|
||||
SUCCEEDED: '성공',
|
||||
FAILED: '실패',
|
||||
CANCELED: '취소'
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function durationLabel(job: BuildJob) {
|
||||
if (job.status === 'PENDING') return '대기 중';
|
||||
if (job.status === 'RUNNING') return '진행 중';
|
||||
if (!job.startedAt || !job.finishedAt) return '-';
|
||||
|
||||
const startedAt = new Date(job.startedAt).getTime();
|
||||
const finishedAt = new Date(job.finishedAt).getTime();
|
||||
if (Number.isNaN(startedAt) || Number.isNaN(finishedAt) || finishedAt < startedAt) return '-';
|
||||
|
||||
const seconds = Math.max(1, Math.round((finishedAt - startedAt) / 1000));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const restSeconds = seconds % 60;
|
||||
return minutes ? `${minutes}분 ${restSeconds}초` : `${restSeconds}초`;
|
||||
}
|
||||
|
||||
function resultArtifact(job: BuildJob) {
|
||||
return job.image || job.artifact || '-';
|
||||
}
|
||||
|
||||
function jobTime(job: BuildJob) {
|
||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
const buildJobPageSize = 3;
|
||||
|
||||
export function BuildManagementPage() {
|
||||
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
||||
const { toast } = useToast();
|
||||
const [sources, setSources] = useState<SourceComponent[]>([]);
|
||||
const [jobs, setJobs] = useState<BuildJob[]>([]);
|
||||
const [selectedComponentId, setSelectedComponentId] = useState('');
|
||||
const [branch, setBranch] = useState('develop');
|
||||
const [commitSha, setCommitSha] = useState('');
|
||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
||||
|
||||
const componentOptions = useMemo(
|
||||
() => {
|
||||
const appSources = selectedAppId ? sources.filter((source) => source.appId === selectedAppId) : sources;
|
||||
return appSources.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
||||
},
|
||||
[selectedAppId, sources]
|
||||
);
|
||||
const selectedComponent = componentOptions.find((source) => source.id === selectedComponentId) ?? componentOptions[0];
|
||||
const selectedTarget = selectedComponent?.buildTarget || buildTargetForType(selectedComponent?.type);
|
||||
const predicted = predictedArtifact(selectedComponent, branch, commitSha);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextSources, nextJobs] = await Promise.all([listSources(), listBuildJobs(selectedApp?.key)]);
|
||||
setSources(nextSources);
|
||||
setJobs(nextJobs);
|
||||
const filteredSources = (selectedAppId ? nextSources.filter((source) => source.appId === selectedAppId) : nextSources)
|
||||
.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
||||
setSelectedComponentId((prev) => {
|
||||
const valid = filteredSources.some((source) => source.id === prev);
|
||||
const next = valid ? prev : filteredSources[0]?.id || '';
|
||||
const nextSource = filteredSources.find((source) => source.id === next);
|
||||
setBranch(nextSource?.branch || 'develop');
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [selectedAppId, selectedApp?.key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!componentOptions.some((source) => source.id === selectedComponentId)) {
|
||||
const nextComponent = componentOptions[0];
|
||||
setSelectedComponentId(nextComponent?.id || '');
|
||||
setBranch(nextComponent?.branch || 'develop');
|
||||
}
|
||||
}, [componentOptions, selectedComponentId]);
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return jobs
|
||||
.filter((job) => {
|
||||
const text = `${job.component.app.name} ${job.component.name} ${job.component.repository ?? ''} ${job.branch} ${job.commitSha ?? ''} ${resultArtifact(job)} ${job.status}`.toLowerCase();
|
||||
const matchesSearch = normalizedSearch ? text.includes(normalizedSearch) : true;
|
||||
const matchesStatus = statusFilter ? job.status === statusFilter : true;
|
||||
return matchesSearch && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => jobTime(b) - jobTime(a));
|
||||
}, [jobs, search, statusFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredJobs.length / buildJobPageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * buildJobPageSize;
|
||||
const pagedJobs = filteredJobs.slice(pageStart, pageStart + buildJobPageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, statusFilter, selectedAppId]);
|
||||
|
||||
const startBuild = async () => {
|
||||
if (!selectedComponent) return;
|
||||
try {
|
||||
const result = await createBuildJob({ componentId: selectedComponent.id, branch, commitSha });
|
||||
toast({ type: 'success', message: '빌드 작업이 생성되었습니다.' });
|
||||
setSelectedJob(result.buildJob);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 생성에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const retryBuild = async (job: BuildJob) => {
|
||||
try {
|
||||
const result = await createBuildJob({ componentId: job.componentId, branch: job.branch, commitSha: job.commitSha ?? '' });
|
||||
toast({ type: 'success', message: '빌드 작업이 다시 생성되었습니다.' });
|
||||
setSelectedJob(result.buildJob);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 재실행에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const stopJob = async (id: string) => {
|
||||
try {
|
||||
await cancelBuildJob(id);
|
||||
toast({ type: 'success', message: '빌드 중지를 요청했습니다.' });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 중지에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const removeJob = async (id: string) => {
|
||||
try {
|
||||
await deleteBuildJob(id);
|
||||
toast({ type: 'success', message: '빌드 작업이 삭제되었습니다.' });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '삭제에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="빌드 관리"
|
||||
description="저장소 소스에서 컨테이너 이미지와 런처 다운로드용 클라이언트 산출물을 만드는 작업을 관리합니다."
|
||||
/>
|
||||
|
||||
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader title="빌드 요청" description="구성요소, 브랜치, 커밋 기준으로 빌드 작업을 생성합니다." />
|
||||
<div className="grid gap-4 p-5 xl:grid-cols-[1.2fr_1fr]">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={selectedComponent?.id ?? ''}
|
||||
onChange={(event) => {
|
||||
const next = componentOptions.find((source) => source.id === event.target.value);
|
||||
setSelectedComponentId(event.target.value);
|
||||
setBranch(next?.branch || 'develop');
|
||||
}}
|
||||
>
|
||||
{componentOptions.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||
{componentOptions.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Branch</span>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={branch}
|
||||
onChange={(event) => setBranch(event.target.value)}
|
||||
>
|
||||
<option value="develop">develop</option>
|
||||
<option value="release">release</option>
|
||||
<option value="main">main</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={commitSha}
|
||||
onChange={(event) => setCommitSha(event.target.value)}
|
||||
placeholder="비워두면 브랜치 최신 커밋 기준"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Repository</span>
|
||||
<span className="truncate font-medium">{selectedComponent?.repository || '미연결'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">빌드 대상</span>
|
||||
<span className="truncate font-medium">{selectedTarget}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">예상 결과물</span>
|
||||
<span className="truncate font-medium">{predicted}</span>
|
||||
</div>
|
||||
<Button className="mt-1 h-8 px-3 text-[13px]" onClick={startBuild} disabled={!selectedComponent || appsLoading || loading}>
|
||||
<Play className="size-4" />
|
||||
빌드 실행
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<h2 className="text-sm font-semibold text-foreground">빌드 작업 목록</h2>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="앱, 구성요소, 저장소 검색"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
{[...new Set(jobs.map((job) => job.status))].map((status) => (
|
||||
<option key={status} value={status}>{statusLabel(status)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
{!loading && filteredJobs.length === 0 ? (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
빌드 작업이 없습니다.
|
||||
</div>
|
||||
) : null}
|
||||
{pagedJobs.map((job, index) => (
|
||||
<div key={job.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. {filteredJobs.length - pageStart - index}</span>
|
||||
<StatusBadge status={statusLabel(job.status)} />
|
||||
<span className="font-medium">{job.component.app.name}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="font-medium">{job.component.name}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||
<span className="text-muted-foreground">대상 <b className="text-foreground">{job.component.buildTarget || buildTargetForType(job.component.type)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-2">
|
||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
{!isBuildDone(job.status) ? (
|
||||
<Button variant="outline" className="size-8 px-0" title="중지" aria-label="중지" onClick={() => stopJob(job.id)}>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className="size-8 px-0" title="재실행" aria-label="재실행" onClick={() => retryBuild(job)}>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isBuildDone(job.status) ? (
|
||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => removeJob(job.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,1fr)_220px_120px]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground">결과물</p>
|
||||
<p className="mt-1 truncate font-mono font-medium">{resultArtifact(job)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">시작 / 종료</p>
|
||||
<p className="mt-1 font-medium">{formatDate(job.startedAt || job.createdAt)} / {formatDate(job.finishedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">소요시간</p>
|
||||
<p className="mt-1 font-medium">{durationLabel(job)}</p>
|
||||
</div>
|
||||
</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 ? '불러오는 중' : `총 ${filteredJobs.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>
|
||||
|
||||
<Modal
|
||||
open={Boolean(selectedJob)}
|
||||
title="빌드 로그"
|
||||
onClose={() => setSelectedJob(null)}
|
||||
className="log-modal-shell"
|
||||
footer={<Button variant="outline" onClick={() => setSelectedJob(null)}>닫기</Button>}
|
||||
>
|
||||
{selectedJob?.logs ? (
|
||||
<pre className="whitespace-pre-wrap bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
||||
{selectedJob.logs}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
작업을 선택하면 로그가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
||||
import { Button, Card, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { AppRelease, RuntimeControlJob } from '../../types/application';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { StatusBadge, formatDate, useApps } from './ApplicationControls';
|
||||
import { createMigration, listMigrations, listReleases } from './application-api';
|
||||
|
||||
type ImageTags = {
|
||||
images?: Record<string, string>;
|
||||
artifacts?: Record<string, string>;
|
||||
};
|
||||
|
||||
function imageMap(imageTags: unknown) {
|
||||
if (!imageTags || typeof imageTags !== 'object') return {};
|
||||
const tags = imageTags as ImageTags;
|
||||
return tags.images ?? tags.artifacts ?? {};
|
||||
}
|
||||
|
||||
function imageEntries(imageTags: unknown) {
|
||||
return Object.entries(imageMap(imageTags));
|
||||
}
|
||||
|
||||
function selectedImageTag(release: AppRelease | undefined, imageName: string) {
|
||||
if (!release || !imageName) return '-';
|
||||
return imageMap(release.imageTags)[imageName] ?? '-';
|
||||
}
|
||||
|
||||
function requestText(job: RuntimeControlJob, key: string, fallback = '-') {
|
||||
const value = job.request?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value : fallback;
|
||||
}
|
||||
|
||||
function jobNumber(index: number, total: number) {
|
||||
return total - index;
|
||||
}
|
||||
|
||||
function migrationTime(job: RuntimeControlJob) {
|
||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
const migrationPageSize = 5;
|
||||
|
||||
export function MigrationManagementPage() {
|
||||
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
||||
const { toast } = useToast();
|
||||
const [jobs, setJobs] = useState<RuntimeControlJob[]>([]);
|
||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||
const [releaseId, setReleaseId] = useState('');
|
||||
const [migrationComponentId, setMigrationComponentId] = useState('');
|
||||
const [imageComponentName, setImageComponentName] = useState('');
|
||||
const [namespace, setNamespace] = useState('');
|
||||
const [command, setCommand] = useState('npx prisma migrate deploy --schema=prisma/schema.prisma');
|
||||
const [envSecretName, setEnvSecretName] = useState('');
|
||||
const [selectedJob, setSelectedJob] = useState<RuntimeControlJob | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
||||
|
||||
const selectedRelease = releases.find((release) => release.id === releaseId);
|
||||
const migrationComponents = selectedApp?.components?.filter((component) => component.type === 'MIGRATION') ?? [];
|
||||
const images = imageEntries(selectedRelease?.imageTags);
|
||||
const selectedImage = selectedImageTag(selectedRelease, imageComponentName);
|
||||
const sortedJobs = [...jobs].sort((a, b) => migrationTime(b) - migrationTime(a));
|
||||
const totalPages = Math.max(1, Math.ceil(sortedJobs.length / migrationPageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * migrationPageSize;
|
||||
const pagedJobs = sortedJobs.slice(pageStart, pageStart + migrationPageSize);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextJobs, nextReleases] = await Promise.all([listMigrations(), listReleases(selectedApp?.key)]);
|
||||
const succeededReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
|
||||
setJobs(nextJobs);
|
||||
setReleases(succeededReleases);
|
||||
setReleaseId((prev) => (succeededReleases.some((release) => release.id === prev) ? prev : succeededReleases[0]?.id || ''));
|
||||
setNamespace((prev) => prev || selectedApp?.namespace || '');
|
||||
setMigrationComponentId((prev) => (
|
||||
migrationComponents.some((component) => component.id === prev) ? prev : migrationComponents[0]?.id || ''
|
||||
));
|
||||
setEnvSecretName((prev) => prev || `${selectedApp?.key ?? 'app'}-be-secret`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [selectedApp?.key, selectedApp?.namespace]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextImages = imageEntries(selectedRelease?.imageTags);
|
||||
setImageComponentName((prev) => (nextImages.some(([name]) => name === prev) ? prev : nextImages[0]?.[0] || ''));
|
||||
}, [selectedRelease?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setNamespace(selectedApp?.namespace || '');
|
||||
setMigrationComponentId(migrationComponents[0]?.id || '');
|
||||
setEnvSecretName(`${selectedApp?.key ?? 'app'}-be-secret`);
|
||||
setPage(1);
|
||||
}, [selectedAppId]);
|
||||
|
||||
const runMigration = async () => {
|
||||
try {
|
||||
setConfirmOpen(false);
|
||||
const result = await createMigration({ releaseId, migrationComponentId, imageComponentName, namespace, command, envSecretName });
|
||||
toast({ type: 'success', message: '마이그레이션 작업이 실행되었습니다.' });
|
||||
setSelectedJob(result.migrationJob);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '마이그레이션 실행에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && command.trim());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="마이그레이션"
|
||||
description="마이그레이션 구성요소를 선택하고 릴리즈 기준으로 데이터 변경 작업을 실행합니다."
|
||||
actions={(
|
||||
<Button disabled={!canRun || loading} onClick={() => setConfirmOpen(true)}>
|
||||
<Play className="size-4" />
|
||||
실행
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Card className="mb-4">
|
||||
<div className="grid gap-4 p-5 lg:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">앱</span>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={selectedAppId}
|
||||
disabled={appsLoading || apps.length === 0}
|
||||
onChange={(event) => selectApp(event.target.value)}
|
||||
>
|
||||
{apps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
|
||||
{apps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">릴리즈</span>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={releaseId}
|
||||
onChange={(event) => setReleaseId(event.target.value)}
|
||||
>
|
||||
{releases.length === 0 ? <option value="">릴리즈 없음</option> : null}
|
||||
{releases.map((release) => <option key={release.id} value={release.id}>{release.version}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">마이그레이션 구성요소</span>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={migrationComponentId}
|
||||
onChange={(event) => setMigrationComponentId(event.target.value)}
|
||||
>
|
||||
{migrationComponents.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||
{migrationComponents.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">실행 이미지</span>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={imageComponentName}
|
||||
onChange={(event) => setImageComponentName(event.target.value)}
|
||||
>
|
||||
{images.length === 0 ? <option value="">이미지 없음</option> : null}
|
||||
{images.map(([name]) => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
</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 text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={namespace}
|
||||
onChange={(event) => setNamespace(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">envFrom Secret</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={envSecretName}
|
||||
onChange={(event) => setEnvSecretName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block lg:col-span-3">
|
||||
<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 font-mono text-xs text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4 lg:col-span-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">선택 이미지</p>
|
||||
<p className="mt-2 break-all font-mono text-sm font-medium text-foreground">{selectedImage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">마이그레이션 작업</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">마이그레이션 실행 이력과 로그입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-5">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
등록된 마이그레이션 작업이 없습니다.
|
||||
</div>
|
||||
) : pagedJobs.map((job, index) => {
|
||||
const releaseVersion = requestText(job, 'releaseVersion');
|
||||
const imageName = requestText(job, 'imageComponentName', requestText(job, 'image'));
|
||||
const commandText = requestText(job, 'command', command);
|
||||
|
||||
return (
|
||||
<div key={job.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 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">No. {jobNumber(pageStart + index, sortedJobs.length)}</span>
|
||||
<StatusBadge status={job.status} />
|
||||
<span className="truncate text-base font-medium text-foreground">
|
||||
{selectedApp?.name ?? '앱'} {job.name}
|
||||
</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">{releaseVersion}</b></span>
|
||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
|
||||
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
|
||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="size-8 shrink-0 px-0" onClick={() => setSelectedJob(job)} title="로그" aria-label="로그">
|
||||
<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>
|
||||
<p className="mt-2 break-all font-mono text-xs text-foreground">{commandText}</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 ? '불러오는 중' : `총 ${sortedJobs.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>
|
||||
|
||||
<Modal open={Boolean(selectedJob)} title="마이그레이션 로그" onClose={() => setSelectedJob(null)} className="log-modal-shell">
|
||||
{selectedJob?.logs ? (
|
||||
<pre className="whitespace-pre-wrap rounded-md border border-border bg-background p-4 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{selectedJob.logs}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
작업을 선택하면 로그가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
title="마이그레이션 실행"
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>취소</Button>
|
||||
<Button disabled={!canRun || loading} onClick={runMigration}>실행</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
선택한 릴리즈 기준으로 데이터베이스 변경 작업을 실행합니다. 적용 전 대상 정보를 확인하세요.
|
||||
</p>
|
||||
<div className="rounded-md border border-border bg-background/70 p-4">
|
||||
<div className="grid gap-3 text-xs md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">앱</p>
|
||||
<p className="mt-1 font-medium text-foreground">{selectedApp?.name ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">릴리즈</p>
|
||||
<p className="mt-1 font-medium text-foreground">{selectedRelease?.version ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">네임스페이스</p>
|
||||
<p className="mt-1 font-medium text-foreground">{namespace || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">이미지</p>
|
||||
<p className="mt-1 break-all font-mono font-medium text-foreground">{selectedImage}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-muted-foreground">명령</p>
|
||||
<p className="mt-1 break-all font-mono text-xs font-medium text-foreground">{command}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, ExternalLink, Link2, Plus } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { GiteaRepo } from '../../types/application';
|
||||
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
||||
import { LoadingModal } from '../operations/operation-utils';
|
||||
import { createGiteaOrgRepo, linkComponentSource, listGiteaRepos, listSources } from './application-api';
|
||||
import { AppSelector, useApps } from './ApplicationControls';
|
||||
|
||||
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
||||
const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222';
|
||||
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform';
|
||||
|
||||
type PlatformRepo = {
|
||||
id: number;
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
description?: string;
|
||||
private: boolean;
|
||||
htmlUrl: string;
|
||||
cloneUrl: string;
|
||||
sshUrl: string;
|
||||
defaultBranch: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
type SourceComponent = ApiAppComponent & {
|
||||
app?: ApiApp;
|
||||
};
|
||||
|
||||
function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
||||
const [visible, setVisible] = useState(loading);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setVisible(false), minimumMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loading, minimumMs]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
function toPlatformRepo(repo: GiteaRepo): PlatformRepo {
|
||||
const fullName = (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? repo.name;
|
||||
const owner =
|
||||
typeof repo.owner === 'string'
|
||||
? repo.owner
|
||||
: repo.owner?.login ?? fullName.split('/')[0] ?? 'platform';
|
||||
|
||||
return {
|
||||
id: repo.id ?? Number(`${owner.length}${repo.name.length}`),
|
||||
owner,
|
||||
name: repo.name,
|
||||
fullName,
|
||||
description: (repo as GiteaRepo & { description?: string }).description,
|
||||
private: Boolean(repo.private),
|
||||
htmlUrl: (repo as GiteaRepo & { htmlUrl?: string }).htmlUrl ?? repo.html_url ?? `${giteaBaseUrl}/${fullName}`,
|
||||
cloneUrl: (repo as GiteaRepo & { cloneUrl?: string }).cloneUrl ?? repo.clone_url ?? `${giteaBaseUrl}/${fullName}.git`,
|
||||
sshUrl: (repo as GiteaRepo & { sshUrl?: string }).sshUrl ?? repo.ssh_url ?? `${giteaSshBaseUrl.replace(/\/$/, '')}/${fullName}.git`,
|
||||
defaultBranch: (repo as GiteaRepo & { defaultBranch?: string }).defaultBranch ?? 'main',
|
||||
language: (repo as GiteaRepo & { language?: string }).language
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepositoryName(repository: string) {
|
||||
const trimmed = repository.trim().replace(/^\/+/, '').replace(/\.git$/, '');
|
||||
return trimmed.includes('/') ? trimmed : `${giteaAppOrg}/${trimmed}`;
|
||||
}
|
||||
|
||||
function getHttpCloneUrl(repository: string) {
|
||||
return `${giteaBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
||||
}
|
||||
|
||||
function getSshCloneUrl(repository: string) {
|
||||
return `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
||||
}
|
||||
|
||||
export function SourceManagementPage() {
|
||||
const { toast } = useToast();
|
||||
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||
const [giteaRepos, setGiteaRepos] = useState<PlatformRepo[]>([]);
|
||||
const [apiSources, setApiSources] = useState<SourceComponent[]>([]);
|
||||
const [sourcesLoading, setSourcesLoading] = useState(true);
|
||||
const [giteaStatus, setGiteaStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const sourcesLoadingVisible = useMinimumLoading(giteaStatus === 'loading' || sourcesLoading);
|
||||
const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false);
|
||||
const [repoName, setRepoName] = useState('');
|
||||
const [repoDescription, setRepoDescription] = useState('');
|
||||
const [repoPrivate, setRepoPrivate] = useState(true);
|
||||
const [createDevelopBranch, setCreateDevelopBranch] = useState(true);
|
||||
const [createReleaseBranch, setCreateReleaseBranch] = useState(true);
|
||||
const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [sourceModalOpen, setSourceModalOpen] = useState(false);
|
||||
const [sourceTarget, setSourceTarget] = useState<SourceComponent | null>(null);
|
||||
const [sourceRepo, setSourceRepo] = useState('');
|
||||
const [sourceBranch, setSourceBranch] = useState('develop');
|
||||
const [sourceBuildTarget, setSourceBuildTarget] = useState('');
|
||||
const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
|
||||
const refreshRepos = () => {
|
||||
setGiteaStatus('loading');
|
||||
listGiteaRepos(giteaAppOrg, 8)
|
||||
.then((repos) => {
|
||||
setGiteaRepos(repos.map(toPlatformRepo));
|
||||
setGiteaStatus('ready');
|
||||
})
|
||||
.catch(() => setGiteaStatus('error'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
setSourcesLoading(true);
|
||||
listSources()
|
||||
.then((sources) => {
|
||||
if (!mounted) return;
|
||||
setApiSources(sources);
|
||||
setSourcesLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setApiSources([]);
|
||||
setSourcesLoading(false);
|
||||
});
|
||||
|
||||
listGiteaRepos(giteaAppOrg, 8)
|
||||
.then((repos) => {
|
||||
if (!mounted) return;
|
||||
setGiteaRepos(repos.map(toPlatformRepo));
|
||||
setGiteaStatus('ready');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setGiteaStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const createRepo = () => {
|
||||
if (!repoName.trim()) return;
|
||||
|
||||
setRepoCreateStatus('saving');
|
||||
createGiteaOrgRepo(giteaAppOrg, {
|
||||
name: repoName.trim(),
|
||||
description: repoDescription.trim() || undefined,
|
||||
private: repoPrivate,
|
||||
autoInit: true,
|
||||
createDevelopBranch,
|
||||
createReleaseBranch
|
||||
})
|
||||
.then(() => {
|
||||
setRepoName('');
|
||||
setRepoDescription('');
|
||||
setRepoPrivate(true);
|
||||
setCreateDevelopBranch(true);
|
||||
setCreateReleaseBranch(true);
|
||||
setRepoCreateStatus('idle');
|
||||
setIsCreateRepoOpen(false);
|
||||
refreshRepos();
|
||||
toast({ type: 'success', message: '저장소가 생성되었습니다.' });
|
||||
})
|
||||
.catch(() => setRepoCreateStatus('error'));
|
||||
};
|
||||
|
||||
const refreshSources = () => {
|
||||
setSourcesLoading(true);
|
||||
listSources()
|
||||
.then((sources) => {
|
||||
setApiSources(sources);
|
||||
setSourcesLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setApiSources([]);
|
||||
setSourcesLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const openSourceModal = (source: SourceComponent) => {
|
||||
setSourceTarget(source);
|
||||
setSourceRepo(source.repository ?? '');
|
||||
setSourceBranch(source.branch ?? 'develop');
|
||||
setSourceBuildTarget(source.buildTarget ?? '');
|
||||
setSourceSaveStatus('idle');
|
||||
setSourceModalOpen(true);
|
||||
};
|
||||
|
||||
const closeSourceModal = () => {
|
||||
setSourceModalOpen(false);
|
||||
setSourceTarget(null);
|
||||
setSourceRepo('');
|
||||
setSourceBranch('develop');
|
||||
setSourceBuildTarget('');
|
||||
setSourceSaveStatus('idle');
|
||||
};
|
||||
|
||||
const saveSourceLink = () => {
|
||||
if (!sourceTarget || !sourceRepo.trim()) return;
|
||||
|
||||
setSourceSaveStatus('saving');
|
||||
linkComponentSource(sourceTarget.id, {
|
||||
repository: sourceRepo,
|
||||
branch: sourceBranch || undefined,
|
||||
buildTarget: sourceBuildTarget || undefined
|
||||
})
|
||||
.then(() => {
|
||||
closeSourceModal();
|
||||
refreshSources();
|
||||
toast({ type: 'success', message: '소스 연결이 저장되었습니다.' });
|
||||
})
|
||||
.catch(() => {
|
||||
setSourceSaveStatus('error');
|
||||
toast({ type: 'error', message: '소스 연결 저장에 실패했습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
void navigator.clipboard?.writeText(value);
|
||||
toast({ type: 'success', message: '복사되었습니다.' });
|
||||
};
|
||||
|
||||
const filteredSources = apiSources.filter((source) => !selectedAppId || source.appId === selectedAppId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="소스 관리"
|
||||
description="앱별 소스, 브랜치, 커밋, 빌드 대상을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateRepoOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
저장소 생성
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
||||
|
||||
<Card className="mb-4 min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title="저장소"
|
||||
description="앱과 런처 소스를 구성요소에 연결할 수 있도록 관리합니다."
|
||||
/>
|
||||
<div className="max-h-[300px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">저장소</th>
|
||||
<th className="px-5 py-3 font-medium">Owner</th>
|
||||
<th className="px-5 py-3 font-medium">기본 브랜치</th>
|
||||
<th className="px-5 py-3 font-medium">언어</th>
|
||||
<th className="px-5 py-3 font-medium">Clone URL</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{giteaStatus === 'error' ? (
|
||||
<tr>
|
||||
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>백엔드 연결 후 저장소가 표시됩니다.</td>
|
||||
</tr>
|
||||
) : giteaStatus !== 'loading' && giteaRepos.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>등록된 저장소가 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
giteaRepos.map((repo) => (
|
||||
<tr key={repo.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{repo.fullName}</span>
|
||||
<a href={repo.htmlUrl} target="_blank" rel="noreferrer" className="text-muted-foreground hover:text-foreground">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
{repo.description ? <p className="mt-1 text-xs text-muted-foreground">{repo.description}</p> : null}
|
||||
</td>
|
||||
<td className="px-5 py-3">{repo.owner}</td>
|
||||
<td className="px-5 py-3">{repo.defaultBranch}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{repo.language || '-'}</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{repo.cloneUrl}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={repo.private ? 'amber' : 'blue'}>{repo.private ? 'Private' : 'Public'}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="소스 연결" description="앱 구성요소별 저장소와 빌드 대상을 확인합니다." />
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1180px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">앱</th>
|
||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||
<th className="w-72 px-5 py-3 font-medium">저장소</th>
|
||||
<th className="px-5 py-3 font-medium">기본 브랜치</th>
|
||||
<th className="px-5 py-3 font-medium">Commit</th>
|
||||
<th className="px-5 py-3 font-medium">유형</th>
|
||||
<th className="px-5 py-3 font-medium">빌드 대상</th>
|
||||
<th className="w-[260px] px-5 py-3 font-medium">Clone URL</th>
|
||||
<th className="w-24 px-5 py-3 font-medium">상태</th>
|
||||
<th className="w-24 px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{!sourcesLoading && filteredSources.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-5 py-6 text-center text-muted-foreground">등록된 앱 구성요소가 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
!sourcesLoading && filteredSources.map((source) => (
|
||||
<tr key={source.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3 font-semibold">{source.app?.name ?? '-'}</td>
|
||||
<td className="px-5 py-3">{source.name}</td>
|
||||
<td className="max-w-72 px-5 py-3 text-muted-foreground">
|
||||
<span className="block truncate whitespace-nowrap">{source.repository || '미연결'}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">{source.branch || '-'}</td>
|
||||
<td className="px-5 py-3 font-medium">-</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{source.type}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{source.buildTarget || '-'}</td>
|
||||
<td className="w-[260px] px-5 py-3 text-muted-foreground">
|
||||
{source.repository ? (
|
||||
<div className="w-[220px] space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate whitespace-nowrap font-mono text-xs">{getHttpCloneUrl(source.repository)}</p>
|
||||
<button
|
||||
type="button"
|
||||
title="HTTP clone URL 복사"
|
||||
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => copyToClipboard(getHttpCloneUrl(source.repository!))}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate whitespace-nowrap font-mono text-xs">{getSshCloneUrl(source.repository)}</p>
|
||||
<button
|
||||
type="button"
|
||||
title="SSH clone URL 복사"
|
||||
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => copyToClipboard(getSshCloneUrl(source.repository!))}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3"><Badge tone={source.repository ? 'green' : 'neutral'}>{source.repository ? '연결됨' : '미연결'}</Badge></td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" className="size-8 px-0" title="소스 연결" aria-label="소스 연결" onClick={() => openSourceModal(source)}>
|
||||
<Link2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isCreateRepoOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
||||
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
||||
<div className="border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-semibold">저장소 생성</h2>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
||||
<input
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||
<textarea
|
||||
value={repoDescription}
|
||||
onChange={(event) => setRepoDescription(event.target.value)}
|
||||
className="mt-2 min-h-20 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={repoPrivate}
|
||||
onChange={(event) => setRepoPrivate(event.target.checked)}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
Private 저장소로 생성
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createDevelopBranch}
|
||||
onChange={(event) => setCreateDevelopBranch(event.target.checked)}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
develop 브랜치 자동 생성
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createReleaseBranch}
|
||||
onChange={(event) => setCreateReleaseBranch(event.target.checked)}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
release 브랜치 자동 생성
|
||||
</label>
|
||||
{repoCreateStatus === 'error' ? (
|
||||
<p className="text-xs text-rose-300">저장소 생성에 실패했습니다. 권한과 중복 저장소명을 확인하세요.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<Button variant="ghost" onClick={() => setIsCreateRepoOpen(false)}>취소</Button>
|
||||
<Button onClick={createRepo} disabled={!repoName.trim() || repoCreateStatus === 'saving'}>
|
||||
{repoCreateStatus === 'saving' ? '생성 중' : '생성'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sourceModalOpen && sourceTarget ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
||||
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
||||
<div className="border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-semibold">소스 연결</h2>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">구성요소</p>
|
||||
<p className="mt-1 text-sm font-semibold">{sourceTarget.app?.name ?? '-'} / {sourceTarget.name}</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">저장소</span>
|
||||
<select
|
||||
value={sourceRepo}
|
||||
onChange={(event) => setSourceRepo(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">저장소 선택</option>
|
||||
{giteaRepos.map((repo) => (
|
||||
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">기본 브랜치</span>
|
||||
<select
|
||||
value={sourceBranch}
|
||||
onChange={(event) => setSourceBranch(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||
>
|
||||
<option value="develop">develop</option>
|
||||
<option value="release">release</option>
|
||||
<option value="main">main</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">빌드 대상</span>
|
||||
<input
|
||||
value={sourceBuildTarget}
|
||||
onChange={(event) => setSourceBuildTarget(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
{sourceSaveStatus === 'error' ? <p className="text-xs text-rose-300">소스 연결 저장에 실패했습니다.</p> : null}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<Button variant="ghost" onClick={closeSourceModal}>취소</Button>
|
||||
<Button onClick={saveSourceLink} disabled={!sourceRepo.trim() || sourceSaveStatus === 'saving'}>
|
||||
{sourceSaveStatus === 'saving' ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LoadingModal open={sourcesLoadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { filterAppsByCurrentAdmin } from '../../lib/admin-app-permissions';
|
||||
import type {
|
||||
AppRelease,
|
||||
AppWithComponents,
|
||||
BuildJob,
|
||||
ComponentType,
|
||||
DeploymentJob,
|
||||
GiteaRepo,
|
||||
RuntimeControlJob,
|
||||
SourceComponent
|
||||
} from '../../types/application';
|
||||
import type { ApiAppComponent } from '../../types/runtime';
|
||||
|
||||
export function listApps() {
|
||||
return apiRequest<AppWithComponents[]>('/apps').then(filterAppsByCurrentAdmin);
|
||||
}
|
||||
|
||||
export function createApp(input: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
return apiRequest<{ app: AppWithComponents }>('/apps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function createComponent(
|
||||
appId: string,
|
||||
input: {
|
||||
name: string;
|
||||
type: ComponentType;
|
||||
imageSource?: string;
|
||||
externalImage?: string;
|
||||
workloadType?: string;
|
||||
replicas?: number;
|
||||
serviceType?: string;
|
||||
storageEnabled?: boolean;
|
||||
storageSize?: string;
|
||||
storageMountPath?: string;
|
||||
storageRetain?: boolean;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
buildTarget?: string;
|
||||
port?: number;
|
||||
targetPort?: number;
|
||||
ports?: Array<{ name?: string; port?: number; targetPort?: number; protocol?: string }>;
|
||||
}
|
||||
) {
|
||||
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listSources() {
|
||||
return apiRequest<SourceComponent[]>('/apps/sources');
|
||||
}
|
||||
|
||||
export function linkComponentSource(
|
||||
componentId: string,
|
||||
input: { repository: string; branch?: string; buildTarget?: string }
|
||||
) {
|
||||
return apiRequest<{ component: SourceComponent }>(`/apps/components/${componentId}/source`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listGiteaRepos(owner = 'platform', limit = 50) {
|
||||
return apiRequest<{ owner: string | null; repos: GiteaRepo[] }>(
|
||||
`/gitea/repos?owner=${encodeURIComponent(owner)}&limit=${limit}`
|
||||
).then((result) => result.repos);
|
||||
}
|
||||
|
||||
export function createGiteaOrgRepo(
|
||||
org: string,
|
||||
input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
private?: boolean;
|
||||
autoInit?: boolean;
|
||||
createDevelopBranch?: boolean;
|
||||
createReleaseBranch?: boolean;
|
||||
}
|
||||
) {
|
||||
return apiRequest<{ repo: GiteaRepo; branches: unknown }>(`/gitea/orgs/${encodeURIComponent(org)}/repos`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}).then((result) => result.repo);
|
||||
}
|
||||
|
||||
export function listBuildJobs(appKey?: string) {
|
||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||
return apiRequest<BuildJob[]>(`/apps/builds${query}`);
|
||||
}
|
||||
|
||||
export function createBuildJob(input: { componentId: string; branch?: string; commitSha?: string }) {
|
||||
return apiRequest<{ buildJob: BuildJob }>('/apps/builds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelBuildJob(buildJobId: string) {
|
||||
return apiRequest<{ buildJob: BuildJob }>(`/apps/builds/${buildJobId}/cancel`, { method: 'PATCH' });
|
||||
}
|
||||
|
||||
export function deleteBuildJob(buildJobId: string) {
|
||||
return apiRequest<{ deleted: boolean }>(`/apps/builds/${buildJobId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function listReleases(appKey?: string) {
|
||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||
return apiRequest<AppRelease[]>(`/apps/releases${query}`);
|
||||
}
|
||||
|
||||
export function createRelease(input: { appId: string; version: string; branch?: string; buildJobIds: string[] }) {
|
||||
return apiRequest<{ release: AppRelease }>('/apps/releases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listDeployments(appKey?: string) {
|
||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||
return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`);
|
||||
}
|
||||
|
||||
export function createDeployment(input: { releaseId: string; environment: string; namespace: string; updateLauncherAppVersion?: boolean }) {
|
||||
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listMigrations() {
|
||||
return apiRequest<RuntimeControlJob[]>('/apps/migrations');
|
||||
}
|
||||
|
||||
export function createMigration(input: {
|
||||
releaseId: string;
|
||||
migrationComponentId: string;
|
||||
imageComponentName: string;
|
||||
namespace: string;
|
||||
command: string;
|
||||
envSecretName: string;
|
||||
}) {
|
||||
return apiRequest<{ migrationJob: RuntimeControlJob }>('/apps/migrations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user