Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 14:22:24 +09:00
4 changed files with 168 additions and 13 deletions
+1 -1
View File
@@ -483,7 +483,7 @@ function Sidebar({
const isParentActive = hasChildren const isParentActive = hasChildren
? item.children.some((child) => resolveNavView(child) === activeView) ? item.children.some((child) => resolveNavView(child) === activeView)
: activeView === resolveNavView(item); : activeView === resolveNavView(item);
const isMenuOpen = Boolean(normalizedMenuSearch) || openMenus[item.label] || isParentActive; const isMenuOpen = Boolean(normalizedMenuSearch) || (openMenus[item.label] ?? isParentActive);
return ( return (
<div key={item.label}> <div key={item.label}>
+1 -1
View File
@@ -269,7 +269,7 @@ export function LauncherBuildPage() {
<div className="rounded-md border border-border bg-background p-3"> <div className="rounded-md border border-border bg-background p-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<p className="text-xs font-medium text-muted-foreground">클라이언트 빌드 환경변수</p> <p className="text-xs font-medium text-foreground">클라이언트 빌드 환경변수</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">저장 후 실행한 빌드부터 반영됩니다.</p> <p className="mt-1 text-xs leading-5 text-muted-foreground">저장 후 실행한 빌드부터 반영됩니다.</p>
</div> </div>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={persistBuildEnv} disabled={buildEnvSaving}> <Button variant="outline" className="h-8 px-3 text-[13px]" onClick={persistBuildEnv} disabled={buildEnvSaving}>
+136 -11
View File
@@ -1,4 +1,4 @@
import { Plus, RotateCcw } from 'lucide-react'; import { Play, Plus, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
@@ -6,8 +6,12 @@ import { PageTitle } from '../../layout/AppLayout';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { import {
createLauncherVersion, createLauncherVersion,
deployLauncherBackend,
getLauncherBackendDeployment,
listLauncherBuildJobs, listLauncherBuildJobs,
listLauncherVersions, listLauncherVersions,
type LauncherBackendDeployment,
type LauncherBackendDeployResult,
type LauncherBuildJob, type LauncherBuildJob,
type LauncherVersion type LauncherVersion
} from './launcher-api'; } from './launcher-api';
@@ -22,11 +26,16 @@ function displayValue(value?: string | null) {
export function LauncherVersionsPage() { export function LauncherVersionsPage() {
const { toast } = useToast(); const { toast } = useToast();
const [tab, setTab] = useState<'client' | 'backend'>('client');
const [versions, setVersions] = useState<LauncherVersion[]>([]); const [versions, setVersions] = useState<LauncherVersion[]>([]);
const [buildJobs, setBuildJobs] = useState<LauncherBuildJob[]>([]); 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 [loading, setLoading] = useState(true);
const [releaseOpen, setReleaseOpen] = useState(false); const [releaseOpen, setReleaseOpen] = useState(false);
const [deployOpen, setDeployOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deploying, setDeploying] = useState(false);
const [releaseForm, setReleaseForm] = useState({ const [releaseForm, setReleaseForm] = useState({
buildJobId: '', buildJobId: '',
version: '', version: '',
@@ -36,7 +45,15 @@ export function LauncherVersionsPage() {
}); });
const loadingVisible = useMinimumLoading(loading); const loadingVisible = useMinimumLoading(loading);
const launcher = useMemo(() => versions.find((item) => item.isActive) ?? versions[0], [versions]); const launcher = useMemo(() => versions.find((item) => item.isActive) ?? versions[0], [versions]);
const successfulBuildJobs = useMemo(() => buildJobs.filter((job) => job.status === 'SUCCEEDED'), [buildJobs]); 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( const selectedBuildJob = useMemo(
() => successfulBuildJobs.find((job) => job.id === releaseForm.buildJobId), () => successfulBuildJobs.find((job) => job.id === releaseForm.buildJobId),
[releaseForm.buildJobId, successfulBuildJobs] [releaseForm.buildJobId, successfulBuildJobs]
@@ -45,10 +62,11 @@ export function LauncherVersionsPage() {
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
Promise.all([listLauncherVersions(), listLauncherBuildJobs()]) Promise.all([listLauncherVersions(), listLauncherBuildJobs(), getLauncherBackendDeployment()])
.then(([nextVersions, nextBuildJobs]) => { .then(([nextVersions, nextBuildJobs, nextBackendDeployment]) => {
setVersions(nextVersions); setVersions(nextVersions);
setBuildJobs(nextBuildJobs); setBuildJobs(nextBuildJobs);
setBackendDeployment(nextBackendDeployment);
}) })
.catch((error: Error) => { .catch((error: Error) => {
toast({ type: 'error', title: '런처 버전 조회 실패', message: error.message }); toast({ type: 'error', title: '런처 버전 조회 실패', message: error.message });
@@ -107,29 +125,61 @@ export function LauncherVersionsPage() {
.finally(() => setSaving(false)); .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 ( return (
<> <>
<PageTitle <PageTitle
eyebrow="런처 운영" eyebrow="런처 운영"
title="버전 관리" title="버전 관리"
description="스튜디오 메인 런처의 버전과 배포 파일 정보를 관리합니다." description="스튜디오 메인 런처 클라이언트 릴리즈와 백엔드 배포를 관리합니다."
actions={ actions={
<> <>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}> <Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} /> <RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
새로고침 새로고침
</Button> </Button>
<Button className="h-8 px-3 text-[13px]" onClick={openRelease}> {tab === 'client' ? (
<Plus className="size-4" /> <Button className="h-8 px-3 text-[13px]" onClick={openRelease}>
릴리즈 등록 <Plus className="size-4" />
</Button> 릴리즈 등록
</Button>
) : (
<Button className="h-8 px-3 text-[13px]" onClick={() => setDeployOpen(true)} disabled={!latestBackendBuild?.downloadUrl}>
<Play className="size-4" />
백엔드 배포
</Button>
)}
</> </>
} }
/> />
{!loading && !launcher ? <Card className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 런처 버전이 없습니다.</Card> : null} <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>
{launcher ? ( {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)]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(360px,0.65fr)]">
<Card> <Card>
<CardHeader title="메인 런처 배포 상태" description="현재 배포 기준으로 사용하는 런처 정보입니다." /> <CardHeader title="메인 런처 배포 상태" description="현재 배포 기준으로 사용하는 런처 정보입니다." />
@@ -183,6 +233,59 @@ export function LauncherVersionsPage() {
</div> </div>
) : null} ) : 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 className="flex justify-end">
<Button onClick={() => setDeployOpen(true)} disabled={!latestBackendBuild?.downloadUrl || deploying}>
<Play className="size-4" />
백엔드 배포
</Button>
</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 <Modal
open={releaseOpen} open={releaseOpen}
title="릴리즈 등록" title="릴리즈 등록"
@@ -237,6 +340,28 @@ export function LauncherVersionsPage() {
</form> </form>
</Modal> </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} /> <LoadingModal open={loadingVisible} />
</> </>
); );
+30
View File
@@ -36,6 +36,25 @@ export type LauncherBuildJob = {
updatedAt?: string; updatedAt?: string;
}; };
export type LauncherBackendDeployment = {
namespace: string;
deployment: string;
container: string;
currentImage?: string | null;
available: boolean;
source?: LauncherSource['backend'];
latestBuild?: LauncherBuildJob | null;
};
export type LauncherBackendDeployResult = {
deployed: boolean;
namespace: string;
deployment: string;
container: string;
image: string;
logs: string;
};
export type LauncherBuildEnvVariable = { export type LauncherBuildEnvVariable = {
key: string; key: string;
value: string; value: string;
@@ -199,6 +218,17 @@ export function listLauncherBuildJobs() {
return apiRequest<LauncherBuildJob[]>('/launcher/builds'); return apiRequest<LauncherBuildJob[]>('/launcher/builds');
} }
export function getLauncherBackendDeployment() {
return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment');
}
export function deployLauncherBackend(image: string) {
return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', {
method: 'POST',
body: JSON.stringify({ image })
});
}
export function listLauncherBuildEnv() { export function listLauncherBuildEnv() {
return apiRequest<{ variables: LauncherBuildEnvVariable[] }>('/launcher/build-env'); return apiRequest<{ variables: LauncherBuildEnvVariable[] }>('/launcher/build-env');
} }