import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import { formatDateTime } from '../../lib/date-format'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { createLauncherBuildJob, getLauncherSource, listLauncherBuildJobs, listLauncherVersions, type LauncherSource, type LauncherBuildJob, type LauncherVersion } from './launcher-api'; const r2PublicBaseUrl = import.meta.env.VITE_R2_PUBLIC_BASE_URL ?? ''; const r2ArtifactPrefix = import.meta.env.VITE_R2_ARTIFACT_PREFIX ?? 'studio-artifacts'; function formatDate(value: string) { return formatDateTime(value); } function statusText(status: LauncherBuildJob['status']) { if (status === 'SUCCEEDED') return '성공'; if (status === 'RUNNING') return '실행 중'; if (status === 'PENDING') return '대기'; if (status === 'FAILED') return '실패'; if (status === 'CANCELED') return '취소'; return status; } function statusTone(status: LauncherBuildJob['status']): 'neutral' | 'green' | 'amber' | 'red' | 'blue' { if (status === 'SUCCEEDED') return 'green'; if (status === 'RUNNING') return 'blue'; if (status === 'PENDING') return 'amber'; if (status === 'FAILED') return 'red'; return 'neutral'; } function jobTime(job: LauncherBuildJob) { const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime(); return Number.isNaN(value) ? 0 : value; } const launcherBuildJobPageSize = 3; type BuildTarget = 'launcher-client' | 'launcher-backend'; function sanitizeObjectKeyPart(value: string) { return value .trim() .replace(/^\/+|\/+$/g, '') .replace(/[^a-zA-Z0-9._@+=-]+/g, '-') || 'unknown'; } function encodeObjectKey(key: string) { return key.split('/').map(encodeURIComponent).join('/'); } function launcherArtifactKey(version: string, branch: string, commitSha: string, artifactName: string) { const revision = commitSha.trim().slice(0, 7) || 'latest'; return [ r2ArtifactPrefix, 'launcher', version || '0.0.0', branch || 'release', revision, artifactName, ].map(sanitizeObjectKeyPart).join('/'); } export function LauncherBuildPage() { const { toast } = useToast(); const [target, setTarget] = useState('launcher-client'); const [versions, setVersions] = useState([]); const [source, setSource] = useState(null); const [version, setVersion] = useState(''); const [branch, setBranch] = useState('release'); const [commitSha, setCommitSha] = useState(''); const [jobs, setJobs] = useState([]); const [selectedJob, setSelectedJob] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const loadingVisible = useMinimumLoading(loading); const activeVersion = useMemo(() => versions.find((item) => item.isActive) ?? versions[0], [versions]); const isBackend = target === 'launcher-backend'; const backendSource = source?.backend; const clientSource = source?.client; const artifactName = `autonomy-studio-launcher-${version || activeVersion?.version || '0.0.0'}.deb`; const backendImageName = backendSource?.image || 'localhost:5000/studio-launcher-be'; const backendImageTag = `${backendImageName.replace(/\/$/, '')}:${version || '0.1.0'}`; const backendDockerfile = backendSource?.dockerfile || 'Dockerfile'; const uploadPath = launcherArtifactKey(version || activeVersion?.version || '0.0.0', branch, commitSha, artifactName); const downloadUrl = r2PublicBaseUrl ? `${r2PublicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(uploadPath)}` : '백엔드 R2 설정 기준으로 생성됩니다.'; const sortedJobs = useMemo(() => [...jobs].filter((job) => job.target === target).sort((a, b) => jobTime(b) - jobTime(a)), [jobs, target]); const totalPages = Math.max(1, Math.ceil(sortedJobs.length / launcherBuildJobPageSize)); const currentPage = Math.min(page, totalPages); const pageStart = (currentPage - 1) * launcherBuildJobPageSize; const pagedJobs = sortedJobs.slice(pageStart, pageStart + launcherBuildJobPageSize); const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING'); const refresh = () => { setLoading(true); Promise.all([listLauncherVersions(), listLauncherBuildJobs(), getLauncherSource()]) .then(([nextVersions, nextJobs, nextSource]) => { setVersions(nextVersions); setJobs(nextJobs); setSource(nextSource.source); const nextVersion = nextVersions.find((item) => item.isActive) ?? nextVersions[0]; setVersion(nextVersion?.version ?? ''); setBranch(target === 'launcher-backend' ? nextSource.source.backend?.branch ?? 'release' : nextSource.source.client?.branch ?? nextVersion?.branch ?? 'release'); setCommitSha(target === 'launcher-backend' ? nextSource.source.backend?.commitSha ?? '' : nextSource.source.client?.commitSha ?? nextVersion?.commitSha ?? ''); }) .catch((error: Error) => { toast({ type: 'error', title: '런처 버전 조회 실패', message: error.message }); }) .finally(() => setLoading(false)); }; useEffect(() => { refresh(); }, []); useEffect(() => { if (!source) return; if (target === 'launcher-backend') { setBranch(source.backend?.branch ?? 'release'); setCommitSha(source.backend?.commitSha ?? ''); return; } setBranch(source.client?.branch ?? activeVersion?.branch ?? 'release'); setCommitSha(source.client?.commitSha ?? activeVersion?.commitSha ?? ''); }, [activeVersion?.branch, activeVersion?.commitSha, source, target]); useEffect(() => { if (!hasActiveJob) return; const timer = window.setInterval(() => { listLauncherBuildJobs() .then((nextJobs) => setJobs(nextJobs)) .catch((error: Error) => { toast({ type: 'error', title: '빌드 작업 갱신 실패', message: error.message }); }); }, 2000); return () => window.clearInterval(timer); }, [hasActiveJob, toast]); useEffect(() => { if (!selectedJob) return; const nextSelectedJob = jobs.find((job) => job.id === selectedJob.id); if (nextSelectedJob && nextSelectedJob !== selectedJob) { setSelectedJob(nextSelectedJob); } }, [jobs, selectedJob]); const createJob = () => { setConfirmOpen(false); setLoading(true); createLauncherBuildJob({ target, repository: isBackend ? backendSource?.repository ?? undefined : clientSource?.repository ?? activeVersion?.repository ?? undefined, branch, commitSha: commitSha.trim() || undefined, version: isBackend ? version.trim() : undefined, uploadPath: isBackend ? backendDockerfile : uploadPath, artifact: isBackend ? backendImageName.replace(/^localhost:5000\//, '') : undefined, downloadUrl: isBackend ? backendImageTag : r2PublicBaseUrl ? downloadUrl : undefined }) .then(({ job }) => { setJobs((prev) => [job, ...prev]); setPage(1); setSelectedJob(job); toast({ type: 'success', title: '빌드 작업', message: '런처 빌드 작업이 생성되었습니다.' }); }) .catch((error: Error) => { toast({ type: 'error', title: '빌드 작업 실패', message: error.message }); }) .finally(() => setLoading(false)); }; return ( <> setConfirmOpen(true)} disabled={isBackend && !version.trim()}> 실행 } />
{isBackend ? ( ) : (

버전

빌드 후 package.json 기준으로 자동 확정

)} {isBackend ? (

이미지

{backendImageTag}

Repository

{backendSource?.repository || '-'}

) : null}
{jobs.length === 0 ? (
빌드 작업이 없습니다.
) : null} {pagedJobs.map((job, index) => (
No. {sortedJobs.length - (pageStart + index)} {statusText(job.status)} {job.target}
브랜치 {job.branch} 커밋 {job.commitSha || '-'} 생성 {formatDate(job.createdAt)}

산출물

{job.artifact}

다운로드 URL

{job.downloadUrl || '-'}

R2 Object Key

{job.uploadPath}

))}
{loading ? '불러오는 중' : `총 ${sortedJobs.length}건 · ${currentPage} / ${totalPages}`}
setSelectedJob(null)} className="log-modal-shell" footer={} >
          {selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
        
setConfirmOpen(false)} footer={ <> } >

{isBackend ? '런처 백엔드 이미지 빌드' : '런처 클라이언트 설치 파일 빌드'}를 실행합니다.

실행 후 빌드 작업이 생성되고, 작업 목록에서 진행 상태와 로그를 확인할 수 있습니다.

대상 {isBackend ? '백엔드' : '클라이언트'}
저장소 {isBackend ? backendSource?.repository || '-' : clientSource?.repository || activeVersion?.repository || '-'}
브랜치 {branch}
커밋 {commitSha.trim() || '브랜치 최신'}
{isBackend ? (
이미지 {backendImageTag}
) : null}
); }