From 4fce1c21a383ac682c5ea078e27355541c26b37a Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 15 Jul 2026 16:00:53 +0900 Subject: [PATCH] feat: add quick deploy for launcher backend builds --- .../applications/BuildManagementPage.tsx | 71 +++++++++++- .../applications/DeploymentManagementPage.tsx | 2 +- src/pages/applications/application-api.ts | 11 ++ src/pages/launcher/LauncherBuildPage.tsx | 104 +++++++++++++++++- 4 files changed, 180 insertions(+), 8 deletions(-) diff --git a/src/pages/applications/BuildManagementPage.tsx b/src/pages/applications/BuildManagementPage.tsx index b8195c8..270f167 100644 --- a/src/pages/applications/BuildManagementPage.tsx +++ b/src/pages/applications/BuildManagementPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type MouseEvent } from 'react'; -import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react'; +import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2, Upload } from 'lucide-react'; import { Button, Card, CardHeader, Modal } from '../../components/ui'; import { Requester } from '../../components/requester'; import { useToast } from '../../components/toast'; @@ -7,7 +7,7 @@ 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, listBuildJobsPage, listSources } from './application-api'; +import { cancelBuildJob, createBuildJob, createQuickDeployment, deleteBuildJob, listBuildJobsPage, listSources } from './application-api'; import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app'; function blockPageAgentDestructiveClick(event: MouseEvent) { @@ -29,7 +29,7 @@ function buildTargetForType(type?: string | null) { function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) { if (!component) return '-'; - const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch; + const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : ''; 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}`; @@ -64,6 +64,12 @@ function resultArtifact(job: BuildJob) { return job.image || job.artifact || '-'; } +function isQuickDeployable(job: BuildJob) { + return job.status === 'SUCCEEDED' + && Boolean(job.image) + && ['CONTAINER', 'STATIC_WEB'].includes(job.component.type); +} + function jobTime(job: BuildJob) { const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime(); return Number.isNaN(value) ? 0 : value; @@ -95,9 +101,12 @@ export function BuildManagementPage() { const [commitSha, setCommitSha] = useState(''); const [selectedJob, setSelectedJob] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [quickDeployTarget, setQuickDeployTarget] = useState(null); + const [quickDeployNamespace, setQuickDeployNamespace] = useState(''); const [confirmOpen, setConfirmOpen] = useState(false); const [loading, setLoading] = useState(false); const [deletingJob, setDeletingJob] = useState(false); + const [quickDeploying, setQuickDeploying] = useState(false); const [page, setPage] = useState(1); const [range, setRange] = useState('week'); const [totalJobs, setTotalJobs] = useState(0); @@ -276,6 +285,29 @@ export function BuildManagementPage() { } }; + const openQuickDeploy = (job: BuildJob) => { + setQuickDeployTarget(job); + setQuickDeployNamespace(job.component.app.namespace || ''); + }; + + const quickDeploy = async () => { + if (!quickDeployTarget || !quickDeployNamespace.trim() || quickDeploying) return; + setQuickDeploying(true); + try { + await createQuickDeployment({ + buildJobId: quickDeployTarget.id, + environment: 'prod', + namespace: quickDeployNamespace.trim() + }); + toast({ type: 'success', message: '빠른 배포 작업이 실행되었습니다.' }); + setQuickDeployTarget(null); + } catch (error) { + toast({ type: 'error', message: error instanceof Error ? error.message : '빠른 배포에 실패했습니다.' }); + } finally { + setQuickDeploying(false); + } + }; + return ( <> )} + {isQuickDeployable(job) ? ( + + ) : null} {isBuildDone(job.status) ? ( + + + } + > +
+

릴리즈를 만들지 않고 선택한 빌드 이미지로 현재 워크로드를 교체합니다. 런처 앱 버전은 갱신하지 않습니다.

+
+
구성요소{quickDeployTarget?.component.name ?? '-'}
+

이미지

{quickDeployTarget?.image ?? '-'}

+
+ +
+ No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + index)} {statusLabel(deployment.status)} {deployment.app.name} - {deployment.release?.version || '-'} + {deployment.release?.version || '빠른 배포'}
환경 {environmentLabel(deployment.environment)} diff --git a/src/pages/applications/application-api.ts b/src/pages/applications/application-api.ts index e1cc909..35423a8 100644 --- a/src/pages/applications/application-api.ts +++ b/src/pages/applications/application-api.ts @@ -251,6 +251,17 @@ export function createDeployment(input: { }); } +export function createQuickDeployment(input: { + buildJobId: string; + environment: string; + namespace: string; +}) { + return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', { + method: 'POST', + body: JSON.stringify(input) + }); +} + export function listMigrations() { return apiRequest('/apps/migrations'); } diff --git a/src/pages/launcher/LauncherBuildPage.tsx b/src/pages/launcher/LauncherBuildPage.tsx index 5c206da..a19cadd 100644 --- a/src/pages/launcher/LauncherBuildPage.tsx +++ b/src/pages/launcher/LauncherBuildPage.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2 } from 'lucide-react'; +import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2, Upload } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; @@ -7,6 +7,8 @@ import { formatDateTime } from '../../lib/date-format'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { createLauncherBuildJob, + deployLauncherBackend, + getLauncherBackendDeployment, getLauncherSource, listLauncherBuildEnv, listLauncherBuildJobs, @@ -16,6 +18,7 @@ import { type LauncherSource, type LauncherBuildJob, type LauncherBuildEnvVariable, + type LauncherBackendDeployment, type LauncherVersion } from './launcher-api'; @@ -132,6 +135,9 @@ export function LauncherBuildPage() { const [buildEnv, setBuildEnv] = useState([]); const [buildEnvSaving, setBuildEnvSaving] = useState(false); const [selectedJob, setSelectedJob] = useState(null); + const [quickDeployJob, setQuickDeployJob] = useState(null); + const [backendDeployment, setBackendDeployment] = useState(null); + const [quickDeploying, setQuickDeploying] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); @@ -153,6 +159,13 @@ export function LauncherBuildPage() { const pagedJobs = jobs; const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING'); + const isQuickDeployable = (job: LauncherBuildJob) => ( + target === 'launcher-backend' + && job.target === 'launcher-backend' + && job.status === 'SUCCEEDED' + && Boolean(job.downloadUrl) + ); + const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => ( listLauncherBuildJobsPage({ target: nextTarget, @@ -269,6 +282,31 @@ export function LauncherBuildPage() { .finally(() => setLoading(false)); }; + const openQuickDeploy = (job: LauncherBuildJob) => { + setQuickDeployJob(job); + setBackendDeployment(null); + getLauncherBackendDeployment() + .then(setBackendDeployment) + .catch((error: Error) => { + toast({ type: 'error', title: '배포 대상 조회 실패', message: error.message }); + }); + }; + + const quickDeploy = () => { + if (!quickDeployJob?.downloadUrl) return; + setQuickDeploying(true); + deployLauncherBackend(quickDeployJob.downloadUrl) + .then(() => { + toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' }); + setQuickDeployJob(null); + refresh(); + }) + .catch((error: Error) => { + toast({ type: 'error', title: '백엔드 배포 실패', message: error.message }); + }) + .finally(() => setQuickDeploying(false)); + }; + const updateBuildEnv = (index: number, patch: Partial) => { setBuildEnv((prev) => prev.map((variable, variableIndex) => ( variableIndex === index ? { ...variable, ...patch } : variable @@ -440,9 +478,22 @@ export function LauncherBuildPage() { 생성 {formatDate(job.createdAt)}
- +
+ {isQuickDeployable(job) ? ( + + ) : null} + +
@@ -500,6 +551,51 @@ export function LauncherBuildPage() { {selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'} + !quickDeploying && setQuickDeployJob(null)} + footer={ + <> + + + + } + > +
+
+

성공한 백엔드 빌드 이미지를 바로 배포합니다.

+

+ 현재 설정된 복제 수와 CPU, 메모리 제한은 변경하지 않습니다. +

+
+
+
+ 대상 + + {backendDeployment ? `${backendDeployment.namespace}/deployment/${backendDeployment.deployment}` : '배포 대상 확인 중'} + +
+
+ 빌드 + {quickDeployJob?.version || '-'} +
+
+ 이미지 + {quickDeployJob?.downloadUrl || '-'} +
+ {backendDeployment?.currentImage ? ( +
+ 현재 이미지 + {backendDeployment.currentImage} +
+ ) : null} +
+
+