diff --git a/frontend/src/App.css b/frontend/src/App.css index c87a311..fc6773c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -13211,6 +13211,83 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } margin: -8px 0 12px; font-size: 12px; color: var(--text2); line-height: 1.45; } .app-download-build-status--active { color: #5eb5ff; } +.app-download-build-progress { + margin: 0 0 14px; padding: 12px 14px; border-radius: 12px; + border: 1px solid var(--border); background: var(--bg2); +} +.app-download-build-progress--active { + border-color: color-mix(in srgb, #2196f3 45%, var(--border)); + background: color-mix(in srgb, #2196f3 8%, var(--bg2)); +} +.app-download-build-progress--success { + border-color: color-mix(in srgb, #22c55e 45%, var(--border)); + background: color-mix(in srgb, #22c55e 8%, var(--bg2)); +} +.app-download-build-progress--failed { + border-color: color-mix(in srgb, #ef4444 45%, var(--border)); + background: color-mix(in srgb, #ef4444 8%, var(--bg2)); +} +.app-download-build-progress-head { + display: flex; align-items: center; justify-content: space-between; + gap: 10px; flex-wrap: wrap; margin-bottom: 10px; +} +.app-download-build-progress-title { + display: inline-flex; align-items: center; gap: 8px; font-size: 13px; +} +.app-download-build-spinner { + width: 14px; height: 14px; border-radius: 50%; + border: 2px solid color-mix(in srgb, #2196f3 25%, transparent); + border-top-color: #2196f3; + animation: tmb-update-spin 0.8s linear infinite; +} +.app-download-build-badge { + font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 999px; + background: color-mix(in srgb, #2196f3 18%, var(--bg)); + color: #5eb5ff; +} +.app-download-build-elapsed { font-size: 11px; color: var(--text3); font-variant-numeric: tabular-nums; } +.app-download-build-bar { + height: 4px; border-radius: 999px; overflow: hidden; + background: color-mix(in srgb, var(--text3) 15%, transparent); + margin-bottom: 12px; +} +.app-download-build-bar-fill { + height: 100%; width: 40%; border-radius: inherit; + background: linear-gradient(90deg, #2196f3, #5eb5ff); + animation: app-download-build-indeterminate 1.4s ease-in-out infinite; +} +@keyframes app-download-build-indeterminate { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(320%); } +} +.app-download-build-steps { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 8px; +} +.app-download-build-step { + display: flex; align-items: center; gap: 8px; + font-size: 12px; color: var(--text3); line-height: 1.4; +} +.app-download-build-step.active { color: #5eb5ff; font-weight: 600; } +.app-download-build-step.done { color: var(--text2); } +.app-download-build-step-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + border: 2px solid color-mix(in srgb, var(--text3) 50%, transparent); + background: transparent; +} +.app-download-build-step.active .app-download-build-step-dot { + border-color: #2196f3; background: #2196f3; + box-shadow: 0 0 0 3px color-mix(in srgb, #2196f3 25%, transparent); +} +.app-download-build-step.done .app-download-build-step-dot { + border-color: #22c55e; background: #22c55e; +} +.app-download-build-message { + margin: 10px 0 0; font-size: 12px; color: var(--text2); line-height: 1.45; +} +.app-download-build-done-note { + margin: 8px 0 0; font-size: 12px; color: #4ade80; line-height: 1.45; +} .app-download-build-note, .app-download-build-wait { margin: 0 0 12px; font-size: 12px; line-height: 1.5; } diff --git a/frontend/src/components/AppDownloadModal.tsx b/frontend/src/components/AppDownloadModal.tsx index 7680968..36a38ec 100644 --- a/frontend/src/components/AppDownloadModal.tsx +++ b/frontend/src/components/AppDownloadModal.tsx @@ -199,6 +199,114 @@ const PlatformCard: React.FC<{ ); }; +const BUILD_POLL_MS = 4000; + +function formatElapsed(startedAtMs?: number): string { + if (startedAtMs == null || !Number.isFinite(startedAtMs)) return '—'; + const sec = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); + const m = Math.floor(sec / 60); + const s = sec % 60; + return m > 0 ? `${m}분 ${s.toString().padStart(2, '0')}초` : `${s}초`; +} + +function buildStatusLabel(status?: string, building?: boolean): string { + if (building) return '빌드 진행 중'; + switch (status) { + case 'building': return '빌드 진행 중'; + case 'queued': return 'Jenkins 큐 대기'; + case 'success': return '빌드 성공'; + case 'failure': return '빌드 실패'; + case 'aborted': return '빌드 중단'; + case 'error': return '상태 조회 오류'; + case 'disabled': return 'Jenkins 비활성'; + case 'ready': return '빌드 준비됨'; + case 'idle': return '대기 중'; + default: return status ?? '—'; + } +} + +function isTerminalBuildStatus(status?: string, building?: boolean): boolean { + if (building) return false; + return status === 'success' || status === 'failure' || status === 'aborted'; +} + +const DesktopBuildProgressPanel: React.FC<{ + status: DesktopAppBuildStatusDto | null; + hint: string | null; + busy: boolean; + tracking: boolean; + tick: number; +}> = ({ status, hint, busy, tracking, tick }) => { + void tick; + const building = Boolean(status?.building || status?.status === 'queued'); + const active = busy || building || tracking; + const terminal = isTerminalBuildStatus(status?.status, status?.building); + const failed = !building && (status?.status === 'failure' || status?.status === 'error'); + const succeeded = !building && status?.status === 'success'; + + if (!active && !terminal && !hint) return null; + + const steps = [ + { id: 'queue', label: 'Jenkins 빌드 시작', done: Boolean(status?.buildNumber) || building || terminal }, + { + id: 'compile', + label: 'macOS dmg · Windows exe 빌드', + done: succeeded || (building && Boolean(status?.buildNumber)), + active: building && Boolean(status?.buildNumber), + }, + { id: 'deploy', label: '설치 파일 배포', done: succeeded, active: false }, + ]; + + return ( +
+
+
+ {(active || building) && } + {buildStatusLabel(status?.status, status?.building)} + {status?.buildNumber != null && ( + #{status.buildNumber} + )} +
+ {(building || status?.startedAtMs) && ( + + 경과 {formatElapsed(status?.startedAtMs)} + + )} +
+ + {(active || building) && ( +
+
+
+ )} + +
    + {steps.map(step => ( +
  1. + + {step.label} +
  2. + ))} +
+ + {(hint || status?.message) && ( +

{hint ?? status?.message}

+ )} + + {succeeded && ( +

아래에서 macOS·Windows 설치 파일을 다운로드할 수 있습니다.

+ )} +
+ ); +}; + const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) => { const [info, setInfo] = useState(null); const [buildStatus, setBuildStatus] = useState(null); @@ -206,6 +314,8 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) const [error, setError] = useState(null); const [buildBusy, setBuildBusy] = useState(false); const [buildHint, setBuildHint] = useState(null); + const [buildTracking, setBuildTracking] = useState(false); + const [elapsedTick, setElapsedTick] = useState(0); const reloadRelease = useCallback(async () => { const data = await loadDesktopAppReleaseInfo(); @@ -228,29 +338,51 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) }, [reloadRelease, reloadBuildStatus]); const isBuilding = Boolean(buildStatus?.building || buildStatus?.status === 'queued'); + const showBuildProgress = Boolean( + buildBusy || buildTracking || isBuilding + || (buildStatus?.buildNumber && buildStatus.status !== 'idle' && buildStatus.status !== 'ready'), + ); useEffect(() => { - if (!isBuilding) return; - const timer = window.setInterval(() => { + if (!showBuildProgress || buildStatus?.startedAtMs == null) return; + const timer = window.setInterval(() => setElapsedTick(t => t + 1), 1000); + return () => window.clearInterval(timer); + }, [showBuildProgress, buildStatus?.startedAtMs]); + + useEffect(() => { + if (!buildTracking && !isBuilding && !buildBusy) return; + const poll = () => { void reloadBuildStatus().then(st => { - if (st && !st.building && st.status !== 'queued') { + if (!st) return; + if (isTerminalBuildStatus(st.status, st.building)) { + setBuildTracking(false); + void reloadRelease(); + } else if (!st.building && st.status !== 'queued') { void reloadRelease(); } }); - }, 8000); + }; + poll(); + const timer = window.setInterval(poll, BUILD_POLL_MS); return () => window.clearInterval(timer); - }, [isBuilding, reloadBuildStatus, reloadRelease]); + }, [buildTracking, isBuilding, buildBusy, reloadBuildStatus, reloadRelease]); const handleBuild = useCallback(async () => { if (!canBuildDesktop || buildBusy || isBuilding) return; setBuildBusy(true); + setBuildTracking(true); setBuildHint('Jenkins 빌드 요청 중…'); try { const res = await triggerDesktopAppBuild(); setBuildHint(res.message ?? (res.accepted ? '빌드 시작됨' : '빌드 요청 거부')); + if (!res.accepted) setBuildTracking(false); await reloadBuildStatus(); + if (res.accepted) { + window.setTimeout(() => { void reloadBuildStatus(); }, 2000); + } } catch (e) { setBuildHint(e instanceof Error ? e.message : '빌드 요청 실패'); + setBuildTracking(false); } finally { setBuildBusy(false); } @@ -261,7 +393,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) if (loading) return

PC 프로그램 정보 불러오는 중…

; if (error) return

{error}

; - const statusMessage = buildHint ?? buildStatus?.message; + const statusMessage = !showBuildProgress ? (buildHint ?? buildStatus?.message) : null; return ( <> @@ -291,6 +423,16 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) )}
+ {showBuildProgress && ( + + )} + {statusMessage && (

{statusMessage} @@ -319,7 +461,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) {isBuilding && !hasAny && (

- Jenkins에서 macOS dmg · Windows setup.exe를 빌드 중입니다. 완료되면 아래에 다운로드가 표시됩니다. + Tauri macOS dmg · Windows NSIS 빌드가 진행 중입니다. 완료되면 아래에 다운로드가 표시됩니다.

)}