import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2 } 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, listLauncherBuildEnv, listLauncherBuildJobs, listLauncherBuildJobsPage, listLauncherVersions, saveLauncherBuildEnv, type LauncherSource, type LauncherBuildJob, type LauncherBuildEnvVariable, 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 = 5; type BuildTarget = 'launcher-client' | 'launcher-backend'; type BuildRange = 'week' | 'month' | 'all'; const buildRangeLabels: Record = { week: '일주일', month: '한달', all: '전체' }; function buildRangeSince(range: BuildRange) { if (range === 'all') return undefined; const date = new Date(); date.setDate(date.getDate() - (range === 'week' ? 7 : 30)); return date.toISOString(); } 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 [buildEnv, setBuildEnv] = useState([]); const [buildEnvSaving, setBuildEnvSaving] = useState(false); const [selectedJob, setSelectedJob] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [range, setRange] = useState('week'); const [totalJobs, setTotalJobs] = useState(0); 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 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 currentPage = page; const totalPages = Math.max(1, Math.ceil(totalJobs / launcherBuildJobPageSize)); const pagedJobs = jobs; const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING'); const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => ( listLauncherBuildJobsPage({ target: nextTarget, page: nextPage, limit: launcherBuildJobPageSize, since: buildRangeSince(nextRange) }).then((result) => { setJobs(result.jobs); setTotalJobs(result.total); }) ); const refresh = () => { setLoading(true); Promise.all([ listLauncherVersions(), listLauncherBuildJobsPage({ target, page, limit: launcherBuildJobPageSize, since: buildRangeSince(range) }), getLauncherSource(), listLauncherBuildEnv() ]) .then(([nextVersions, nextJobs, nextSource, nextBuildEnv]) => { setVersions(nextVersions); setJobs(nextJobs.jobs); setTotalJobs(nextJobs.total); setSource(nextSource.source); setBuildEnv(nextBuildEnv.variables ?? []); const nextVersion = nextVersions.find((item) => item.isActive) ?? nextVersions[0]; setVersion(nextVersion?.version ?? ''); setBranch('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(() => { setPage(1); }, [target, range]); useEffect(() => { loadBuildJobs().catch((error: Error) => { setJobs([]); setTotalJobs(0); toast({ type: 'error', title: '빌드 작업 조회 실패', message: error.message }); }); }, [target, page, range]); useEffect(() => { if (!source) return; if (target === 'launcher-backend') { setBranch('release'); setCommitSha(source.backend?.commitSha ?? ''); return; } setBranch('release'); setCommitSha(source.client?.commitSha ?? activeVersion?.commitSha ?? ''); }, [activeVersion?.branch, activeVersion?.commitSha, source, target]); useEffect(() => { if (!hasActiveJob) return; const timer = window.setInterval(() => { loadBuildJobs() .catch((error: Error) => { toast({ type: 'error', title: '빌드 작업 갱신 실패', message: error.message }); }); }, 2000); return () => window.clearInterval(timer); }, [hasActiveJob, toast, target, page, range]); 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: undefined, uploadPath: isBackend ? backendDockerfile : uploadPath, artifact: isBackend ? backendImageName.replace(/^localhost:5000\//, '') : undefined, downloadUrl: isBackend ? undefined : r2PublicBaseUrl ? downloadUrl : undefined }) .then(({ job }) => { setPage(1); setRange('week'); setJobs((prev) => [job, ...prev].slice(0, launcherBuildJobPageSize)); setTotalJobs((prev) => prev + 1); setSelectedJob(job); toast({ type: 'success', title: '빌드 작업', message: '런처 빌드 작업이 생성되었습니다.' }); }) .catch((error: Error) => { toast({ type: 'error', title: '빌드 작업 실패', message: error.message }); }) .finally(() => setLoading(false)); }; const updateBuildEnv = (index: number, patch: Partial) => { setBuildEnv((prev) => prev.map((variable, variableIndex) => ( variableIndex === index ? { ...variable, ...patch } : variable ))); }; const removeBuildEnv = (index: number) => { setBuildEnv((prev) => prev.filter((_, variableIndex) => variableIndex !== index)); }; const persistBuildEnv = () => { setBuildEnvSaving(true); saveLauncherBuildEnv( buildEnv .map((variable) => ({ ...variable, key: variable.key.trim() })) .filter((variable) => variable.key) ) .then((result) => { setBuildEnv(result.variables ?? []); toast({ type: 'success', title: '빌드 환경변수 저장', message: '런처 클라이언트 빌드 환경변수를 저장했습니다.' }); }) .catch((error: Error) => { toast({ type: 'error', title: '빌드 환경변수 저장 실패', message: error.message }); }) .finally(() => setBuildEnvSaving(false)); }; return ( <> } />

버전

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

{isBackend ? (

이미지 이름

{backendImageName}

태그는 빌드 후 package.json 버전으로 자동 확정됩니다.

Repository

{backendSource?.repository || '-'}

) : (

클라이언트 빌드 환경변수

저장 후 실행한 빌드부터 반영됩니다.

{buildEnv.map((variable, index) => (
updateBuildEnv(index, { key: event.target.value })} placeholder="VITE_ATNMY_STDO_API_BASE_URL" /> updateBuildEnv(index, { value: event.target.value })} placeholder="https://portal-api.autonomystudio.net" />
))} {buildEnv.length === 0 ?

등록된 빌드 환경변수가 없습니다.

: null}
)}
{(Object.keys(buildRangeLabels) as BuildRange[]).map((item) => ( ))}
} />
{jobs.length === 0 ? (
빌드 작업이 없습니다.
) : null} {pagedJobs.map((job, index) => (
No. {(currentPage - 1) * launcherBuildJobPageSize + index + 1} {statusText(job.status)} {job.target}
브랜치 {job.branch} 커밋 {job.commitSha || '-'} 생성 {formatDate(job.createdAt)}

산출물

{job.artifact}

다운로드 URL

{job.downloadUrl || '-'}

R2 Object Key

{job.uploadPath}

))}
{loading ? '불러오는 중' : `총 ${totalJobs}건 · ${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 ? (
이미지 이름 {backendImageName}
) : null}
); }