Files
studio-admin-fe/src/pages/launcher/LauncherVersionsPage.tsx
T
chyoo ac075a1fea
ci/woodpecker/push/woodpecker Pipeline was successful
improve app registration and component service ports
2026-07-09 14:26:43 +09:00

363 lines
18 KiB
TypeScript

import { Play, Plus, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
createLauncherVersion,
deployLauncherBackend,
getLauncherBackendDeployment,
listLauncherBuildJobs,
listLauncherVersions,
type LauncherBackendDeployment,
type LauncherBackendDeployResult,
type LauncherBuildJob,
type LauncherVersion
} from './launcher-api';
function artifactName(version: string) {
return `autonomy-studio-launcher-${version || '0.0.0'}.deb`;
}
function displayValue(value?: string | null) {
return value?.trim() || '-';
}
export function LauncherVersionsPage() {
const { toast } = useToast();
const [tab, setTab] = useState<'client' | 'backend'>('client');
const [versions, setVersions] = useState<LauncherVersion[]>([]);
const [buildJobs, setBuildJobs] = useState<LauncherBuildJob[]>([]);
const [backendDeployment, setBackendDeployment] = useState<LauncherBackendDeployment | null>(null);
const [deployResult, setDeployResult] = useState<LauncherBackendDeployResult | null>(null);
const [loading, setLoading] = useState(true);
const [releaseOpen, setReleaseOpen] = useState(false);
const [deployOpen, setDeployOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [deploying, setDeploying] = useState(false);
const [releaseForm, setReleaseForm] = useState({
buildJobId: '',
version: '',
minimumVersion: '',
downloadUrl: '',
releaseNotes: ''
});
const loadingVisible = useMinimumLoading(loading);
const launcher = useMemo(() => versions.find((item) => item.isActive) ?? versions[0], [versions]);
const successfulBuildJobs = useMemo(
() => buildJobs.filter((job) => job.target !== 'launcher-backend' && job.status === 'SUCCEEDED'),
[buildJobs]
);
const successfulBackendBuildJobs = useMemo(
() => buildJobs.filter((job) => job.target === 'launcher-backend' && job.status === 'SUCCEEDED'),
[buildJobs]
);
const latestBackendBuild = backendDeployment?.latestBuild ?? successfulBackendBuildJobs[0] ?? null;
const selectedBuildJob = useMemo(
() => successfulBuildJobs.find((job) => job.id === releaseForm.buildJobId),
[releaseForm.buildJobId, successfulBuildJobs]
);
const currentArtifact = selectedBuildJob?.artifact || artifactName(releaseForm.version);
const refresh = () => {
setLoading(true);
Promise.all([listLauncherVersions(), listLauncherBuildJobs(), getLauncherBackendDeployment()])
.then(([nextVersions, nextBuildJobs, nextBackendDeployment]) => {
setVersions(nextVersions);
setBuildJobs(nextBuildJobs);
setBackendDeployment(nextBackendDeployment);
})
.catch((error: Error) => {
toast({ type: 'error', title: '런처 버전 조회 실패', message: error.message });
})
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
}, []);
const openRelease = () => {
const latestBuild = successfulBuildJobs[0];
setReleaseForm({
buildJobId: latestBuild?.id ?? '',
version: latestBuild?.version ?? '',
minimumVersion: launcher?.minimumVersion ?? '',
downloadUrl: latestBuild?.downloadUrl ?? '',
releaseNotes: ''
});
setReleaseOpen(true);
};
const selectBuildJob = (buildJobId: string) => {
const buildJob = successfulBuildJobs.find((job) => job.id === buildJobId);
setReleaseForm((prev) => ({
...prev,
buildJobId,
version: buildJob?.version ?? prev.version,
downloadUrl: buildJob?.downloadUrl ?? prev.downloadUrl
}));
};
const saveRelease = (event: FormEvent) => {
event.preventDefault();
if (!selectedBuildJob || !releaseForm.version.trim() || !releaseForm.downloadUrl.trim()) return;
setSaving(true);
createLauncherVersion({
version: releaseForm.version.trim(),
minimumVersion: releaseForm.minimumVersion.trim() || undefined,
artifact: currentArtifact,
downloadUrl: releaseForm.downloadUrl.trim(),
releaseNotes: releaseForm.releaseNotes.trim() || undefined,
repository: selectedBuildJob?.repository ?? launcher?.repository ?? undefined,
branch: selectedBuildJob?.branch ?? launcher?.branch ?? 'release',
commitSha: selectedBuildJob?.commitSha ?? launcher?.commitSha ?? undefined
})
.then(() => {
toast({ type: 'success', message: '릴리즈를 등록했습니다.' });
setReleaseOpen(false);
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '릴리즈 등록 실패', message: error.message });
})
.finally(() => setSaving(false));
};
const deployBackend = () => {
if (!latestBackendBuild?.downloadUrl) return;
setDeploying(true);
deployLauncherBackend(latestBackendBuild.downloadUrl)
.then((result) => {
setDeployResult(result);
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
setDeployOpen(false);
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '백엔드 배포 실패', message: error.message });
})
.finally(() => setDeploying(false));
};
return (
<>
<PageTitle
eyebrow="런처"
title="버전 관리"
description="스튜디오 메인 런처 클라이언트 릴리즈와 백엔드 배포를 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
새로고침
</Button>
{tab === 'client' ? (
<Button className="h-8 px-3 text-[13px]" onClick={openRelease}>
<Plus className="size-4" />
릴리즈 등록
</Button>
) : (
<Button className="h-8 px-3 text-[13px]" onClick={() => setDeployOpen(true)} disabled={!latestBackendBuild?.downloadUrl}>
<Play className="size-4" />
백엔드 배포
</Button>
)}
</>
}
/>
<div className="mb-4 flex flex-wrap gap-2">
<Button variant={tab === 'client' ? 'default' : 'outline'} onClick={() => setTab('client')}>
클라이언트 릴리즈
</Button>
<Button variant={tab === 'backend' ? 'default' : 'outline'} onClick={() => setTab('backend')}>
백엔드 배포
</Button>
</div>
{tab === 'client' && !loading && !launcher ? <Card className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 런처 버전이 없습니다.</Card> : null}
{tab === 'client' && launcher ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(360px,0.65fr)]">
<Card>
<CardHeader title="메인 런처 배포 상태" description="현재 배포 기준으로 사용하는 런처 정보입니다." />
<div className="space-y-4 p-5">
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/20 p-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
<p className="text-base font-semibold">Autonomy Studio Launcher</p>
<Badge tone={launcher.isActive ? 'green' : 'neutral'}>{launcher.isActive ? '활성' : '비활성'}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">최신 {launcher.version} · 최소 지원 {launcher.minimumVersion || '-'}</p>
</div>
</div>
<div className="grid gap-3">
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">배포 파일</p>
<p className="mt-2 break-all font-mono text-sm font-medium">{launcher.artifact}</p>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{launcher.downloadUrl || '-'}</p>
</div>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">소스 기준</p>
<div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">Repository</p>
<p className="mt-1 truncate font-medium">{launcher.repository || '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Branch</p>
<p className="mt-1 truncate font-medium">{launcher.branch || '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Commit SHA</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{launcher.commitSha || '-'}</p>
</div>
</div>
</div>
</div>
</Card>
<Card>
<CardHeader title="릴리즈 노트" description="최근 런처 배포 메모입니다." />
<div className="p-5">
<div className="min-h-32 rounded-md border border-neutral-800 bg-neutral-950 px-4 py-3 text-sm leading-6 text-neutral-100">
<p className="whitespace-pre-wrap">{launcher.releaseNotes || '-'}</p>
</div>
</div>
</Card>
</div>
) : null}
{tab === 'backend' ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(360px,0.75fr)]">
<Card>
<CardHeader title="런처 백엔드 배포 상태" description="현재 k3s에서 실행 중인 런처 백엔드 이미지와 최신 성공 빌드입니다." />
<div className="space-y-4 p-5">
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">Namespace</p>
<p className="mt-2 font-mono text-sm font-medium">{backendDeployment?.namespace || 'studio-system'}</p>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">Deployment</p>
<p className="mt-2 font-mono text-sm font-medium">{backendDeployment?.deployment || 'studio-launcher-be'}</p>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">상태</p>
<div className="mt-2">
<Badge tone={backendDeployment?.available ? 'green' : 'red'}>{backendDeployment?.available ? '조회됨' : '조회 실패'}</Badge>
</div>
</div>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">현재 배포 이미지</p>
<p className="mt-2 break-all font-mono text-sm font-medium">{backendDeployment?.currentImage || '-'}</p>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">최신 성공 빌드 이미지</p>
<p className="mt-2 break-all font-mono text-sm font-medium">{latestBackendBuild?.downloadUrl || '-'}</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>버전 <b className="text-foreground">{latestBackendBuild?.version || '-'}</b></span>
<span>브랜치 <b className="text-foreground">{latestBackendBuild?.branch || '-'}</b></span>
<span>커밋 <b className="font-mono text-foreground">{latestBackendBuild?.commitSha || '-'}</b></span>
</div>
</div>
</div>
</Card>
<Card>
<CardHeader title="배포 로그" description="마지막 백엔드 배포 실행 결과입니다." />
<div className="p-5">
<pre className="min-h-64 overflow-auto rounded-md border border-neutral-800 bg-neutral-950 p-4 font-mono text-xs leading-6 text-neutral-100">
{deployResult?.logs || '아직 이 화면에서 실행한 배포 로그가 없습니다.'}
</pre>
</div>
</Card>
</div>
) : null}
<Modal
open={releaseOpen}
title="릴리즈 등록"
onClose={() => setReleaseOpen(false)}
className="!max-w-3xl"
footer={<><Button variant="outline" onClick={() => setReleaseOpen(false)}>취소</Button><Button onClick={saveRelease as unknown as () => void} disabled={saving || !selectedBuildJob || !releaseForm.version.trim() || !releaseForm.downloadUrl.trim()}>{saving ? '등록 중' : '등록'}</Button></>}
>
<form className="space-y-5" onSubmit={saveRelease}>
<div className="rounded-md border border-border bg-background px-4 py-3">
<p className="text-sm font-medium">배포 파일 기준 등록</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
</div>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">빌드 결과 <b className="text-foreground">*</b></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={releaseForm.buildJobId}
onChange={(event) => selectBuildJob(event.target.value)}
>
<option value="">빌드 결과 선택</option>
{successfulBuildJobs.map((job) => (
<option key={job.id} value={job.id}>
{job.version} · {job.artifact}
</option>
))}
</select>
</label>
<div className="grid gap-4 md:grid-cols-2">
<div>
<span className="text-xs font-medium text-muted-foreground">버전 <b className="text-foreground">*</b></span>
<div className="mt-2 flex h-9 items-center rounded-md border border-border bg-muted/20 px-3 text-sm font-medium text-foreground">
{displayValue(releaseForm.version)}
</div>
</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 text-foreground outline-none focus:ring-2 focus:ring-ring" value={releaseForm.minimumVersion} onChange={(event) => setReleaseForm((prev) => ({ ...prev, minimumVersion: event.target.value }))} placeholder="예: 1.0.0" />
</label>
</div>
<div className="rounded-md border border-border bg-background px-4 py-3">
<p className="text-xs text-muted-foreground">배포 파일</p>
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
</div>
<div className="rounded-md border border-border bg-background px-4 py-3">
<p className="text-xs text-muted-foreground">다운로드 URL <b className="text-foreground">*</b></p>
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p>
</div>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">릴리즈 노트</span>
<textarea className="mt-2 min-h-28 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={releaseForm.releaseNotes} onChange={(event) => setReleaseForm((prev) => ({ ...prev, releaseNotes: event.target.value }))} />
</label>
</form>
</Modal>
<Modal
open={deployOpen}
title="런처 백엔드 배포"
onClose={() => setDeployOpen(false)}
footer={<><Button variant="outline" onClick={() => setDeployOpen(false)}>취소</Button><Button onClick={deployBackend} disabled={deploying || !latestBackendBuild?.downloadUrl}>{deploying ? '배포 중' : '배포'}</Button></>}
>
<div className="space-y-4 text-sm">
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
<p className="font-medium">최신 성공 빌드 이미지로 런처 백엔드를 배포합니다.</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">Deployment 이미지가 교체되고 rollout 완료까지 대기합니다.</p>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">배포 이미지</p>
<p className="mt-2 break-all font-mono text-xs font-medium">{latestBackendBuild?.downloadUrl || '-'}</p>
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-xs text-muted-foreground">현재 이미지</p>
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{backendDeployment?.currentImage || '-'}</p>
</div>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}