init studio admin frontend
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { 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,
|
||||
listLauncherBuildJobs,
|
||||
listLauncherVersions,
|
||||
type LauncherBuildJob,
|
||||
type LauncherVersion
|
||||
} from './launcher-api';
|
||||
|
||||
function artifactName(version: string) {
|
||||
return `autonomy-studio-launcher-${version || '0.0.0'}.deb`;
|
||||
}
|
||||
|
||||
export function LauncherVersionsPage() {
|
||||
const { toast } = useToast();
|
||||
const [versions, setVersions] = useState<LauncherVersion[]>([]);
|
||||
const [buildJobs, setBuildJobs] = useState<LauncherBuildJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [releaseOpen, setReleaseOpen] = useState(false);
|
||||
const [saving, setSaving] = 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.status === 'SUCCEEDED'), [buildJobs]);
|
||||
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()])
|
||||
.then(([nextVersions, nextBuildJobs]) => {
|
||||
setVersions(nextVersions);
|
||||
setBuildJobs(nextBuildJobs);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '런처 버전 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const openRelease = () => {
|
||||
const nextVersion = '';
|
||||
const latestBuild = successfulBuildJobs[0];
|
||||
setReleaseForm({
|
||||
buildJobId: latestBuild?.id ?? '',
|
||||
version: nextVersion,
|
||||
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 (!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));
|
||||
};
|
||||
|
||||
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>
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openRelease}>
|
||||
<Plus className="size-4" />
|
||||
릴리즈 등록
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!loading && !launcher ? <Card className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 런처 버전이 없습니다.</Card> : null}
|
||||
|
||||
{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 text-sm leading-6 text-muted-foreground">{launcher.releaseNotes || '-'}</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 || !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">빌드가 끝난 `.deb` 파일의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
||||
</div>
|
||||
<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={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">
|
||||
<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.version} onChange={(event) => setReleaseForm((prev) => ({ ...prev, version: event.target.value }))} placeholder="예: 1.0.2" />
|
||||
</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={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">{currentArtifact}</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">다운로드 URL</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={releaseForm.downloadUrl} onChange={(event) => setReleaseForm((prev) => ({ ...prev, downloadUrl: event.target.value }))} placeholder="https://.../autonomy-studio-launcher-1.0.2.deb" />
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user