diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 2357cda6..3851fb1a 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -2887,6 +2887,40 @@ "server_downloaded": "Server binary downloaded", "server_download_failed": "Server binary download failed", "server_download_note": "A pre-built binary will be downloaded from GitHub Releases. This usually takes less than a minute.", - "install_go_hint": "Install Go from https://go.dev/dl/ to enable compilation, or publish a release with pre-built binaries" + "install_go_hint": "Install Go from https://go.dev/dl/ to enable compilation, or publish a release with pre-built binaries", + "strategy_install_go": "Auto-install Go and compile", + "strategy_install_go_hint": "Downloads the official Go toolchain (~120 MB) into the console data directory, then compiles the server. Use this when Go is not installed on the host.", + "auto_strategy": "Auto", + "auto_strategy_hint": "The console will pick the best available method (download → compile → auto-install Go).", + "auto_available": "Auto-rebuild ready", + "toolchain_ready": "Toolchain installed", + "toolchain_will_install": "Go toolchain will be installed automatically", + "toolchain_downloading": "Downloading Go toolchain…", + "toolchain_failed": "Go toolchain install failed", + "modal_title": "Installing update", + "modal_done_title": "Update complete", + "modal_close": "Close", + "modal_reload_now": "Reload console", + "phase_confirm": "Confirmation", + "phase_backup": "Backup", + "phase_console": "Console files", + "phase_server": "Go server", + "phase_restart": "Restart", + "phase_done": "Done", + "preparing": "Preparing…", + "backup_skipped": "Backup skipped (per user choice)", + "server_skipped": "Go server skipped", + "server_not_selected": "Go server not selected", + "server_processing": "Processing Go server update…", + "no_restart_needed": "No restart required", + "complete": "Update applied", + "complete_summary": "The update has been applied successfully.", + "console_will_restart": "Console process will restart now…", + "restart_complete_msg": "The console finished restarting. Click reload to load the new version. The page will reload automatically in 8 seconds.", + "refresh_recommended": "Static assets changed — reload the page to see the new UI.", + "confirm_title": "Apply update", + "confirm_with_backup": "A pre-update backup will be created automatically.", + "confirm_no_backup": "No backup will be created (proceed at your own risk).", + "install": "Install" } } diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 9aee9aee..254274c6 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -2887,6 +2887,40 @@ "server_downloaded": "Binarka serwera pobrana", "server_download_failed": "Pobieranie binarki serwera nie powiodło się", "server_download_note": "Gotowa binarka zostanie pobrana z GitHub Releases. Zwykle trwa to mniej niż minutę.", - "install_go_hint": "Zainstaluj Go z https://go.dev/dl/ aby włączyć kompilację lub opublikuj wydanie z gotowymi binarkami" + "install_go_hint": "Zainstaluj Go z https://go.dev/dl/ aby włączyć kompilację lub opublikuj wydanie z gotowymi binarkami", + "strategy_install_go": "Pobierz Go i skompiluj automatycznie", + "strategy_install_go_hint": "Pobiera oficjalny zestaw Go (~120 MB) do katalogu danych konsoli i kompiluje serwer. Wybierz tę opcję, jeśli Go nie jest zainstalowane na hoście.", + "auto_strategy": "Auto", + "auto_strategy_hint": "Konsola wybierze najlepszą dostępną metodę (pobranie → kompilacja → automatyczna instalacja Go).", + "auto_available": "Automatyczna przebudowa gotowa", + "toolchain_ready": "Zestaw zainstalowany", + "toolchain_will_install": "Zestaw Go zostanie zainstalowany automatycznie", + "toolchain_downloading": "Pobieranie zestawu Go…", + "toolchain_failed": "Instalacja zestawu Go nie powiodła się", + "modal_title": "Instalowanie aktualizacji", + "modal_done_title": "Aktualizacja zakończona", + "modal_close": "Zamknij", + "modal_reload_now": "Odśwież konsolę", + "phase_confirm": "Potwierdzenie", + "phase_backup": "Kopia zapasowa", + "phase_console": "Pliki konsoli", + "phase_server": "Serwer Go", + "phase_restart": "Restart", + "phase_done": "Gotowe", + "preparing": "Przygotowywanie…", + "backup_skipped": "Kopia zapasowa pominięta (wybór operatora)", + "server_skipped": "Serwer Go pominięty", + "server_not_selected": "Serwer Go nie wybrany", + "server_processing": "Przetwarzanie aktualizacji serwera Go…", + "no_restart_needed": "Restart nie jest wymagany", + "complete": "Aktualizacja zastosowana", + "complete_summary": "Aktualizacja została zastosowana pomyślnie.", + "console_will_restart": "Proces konsoli zostanie teraz zrestartowany…", + "restart_complete_msg": "Konsola zakończyła restart. Kliknij Odśwież, aby załadować nową wersję. Strona zostanie odświeżona automatycznie za 8 sekund.", + "refresh_recommended": "Zmieniły się statyczne zasoby — odśwież stronę, aby zobaczyć nowy interfejs.", + "confirm_title": "Zastosuj aktualizację", + "confirm_with_backup": "Kopia zapasowa zostanie utworzona automatycznie.", + "confirm_no_backup": "Kopia zapasowa nie zostanie utworzona (na własne ryzyko).", + "install": "Zainstaluj" } } diff --git a/web-nodejs/public/css/pages.css b/web-nodejs/public/css/pages.css index 8d82f8b0..6aebdd09 100644 --- a/web-nodejs/public/css/pages.css +++ b/web-nodejs/public/css/pages.css @@ -1497,6 +1497,94 @@ margin-top: var(--space-xs); } +/* Modal-based update progress */ +.update-progress-modal { + display: flex; + flex-direction: column; + gap: var(--space-md); + min-width: 480px; +} +.update-progress-modal .update-progress-bar { + height: 8px; + background: var(--bg-tertiary, rgba(255,255,255,0.06)); + border-radius: 4px; + overflow: hidden; +} +.update-progress-modal .update-progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary), var(--accent, var(--primary))); + border-radius: 4px; + transition: width 0.4s ease; + width: 0%; +} +.update-phases { + display: flex; + flex-direction: column; + gap: 6px; +} +.update-phase { + display: grid; + grid-template-columns: 28px 1fr 28px; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 8px; + background: var(--bg-tertiary, rgba(255,255,255,0.03)); + border: 1px solid var(--border, rgba(255,255,255,0.06)); + font-size: var(--font-size-sm); +} +.update-phase-icon { + font-size: 20px; + color: var(--text-secondary); +} +.update-phase-label { + color: var(--text-primary); +} +.update-phase-state .material-icons { + font-size: 20px; + color: var(--text-tertiary, var(--text-secondary)); +} +.update-phase-state[data-state="active"] .material-icons { + color: var(--primary); +} +.update-phase-state[data-state="done"] .material-icons { + color: var(--success, #22c55e); +} +.update-phase-state[data-state="error"] .material-icons { + color: var(--danger, #ef4444); +} +.update-phase-state[data-state="skipped"] .material-icons { + color: var(--text-tertiary, rgba(255,255,255,0.35)); + opacity: 0.7; +} +.update-phase-state .spinning { + animation: update-spin 1.2s linear infinite; +} +@keyframes update-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.update-progress-detail { + font-size: var(--font-size-sm); + color: var(--text-secondary); + min-height: 1.4em; +} +.update-progress-log { + background: var(--bg-secondary, rgba(0,0,0,0.25)); + color: var(--text-secondary); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; + line-height: 1.45; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border, rgba(255,255,255,0.06)); + max-height: 160px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + .update-backups { display: flex; flex-direction: column; diff --git a/web-nodejs/public/js/settings.js b/web-nodejs/public/js/settings.js index 906e5556..b2b0271f 100644 --- a/web-nodejs/public/js/settings.js +++ b/web-nodejs/public/js/settings.js @@ -1386,6 +1386,13 @@ ${_('updates.strategy_compile')} + +
${_('updates.strategy_install_go_hint')}
`; @@ -1407,26 +1414,28 @@ const strategySection = document.getElementById('update-server-strategy'); const downloadRadio = document.getElementById('strategy-download'); const compileRadio = document.getElementById('strategy-compile'); + const installGoRadio = document.getElementById('strategy-install-go'); const downloadBadge = document.getElementById('strategy-download-badge'); const compileBadge = document.getElementById('strategy-compile-badge'); + const installGoBadge = document.getElementById('strategy-install-go-badge'); if (!statusEl || !toggleEl) return; - + try { const info = await Utils.api('/api/settings/updates/server-info'); - const hasGo = info.goAvailable; + const hasGo = !!info.goAvailable; const prebuilt = info.prebuilt || {}; - const hasDownload = prebuilt.available; - const canUpdate = hasGo || hasDownload; - - // Always enable the checkbox if at least one strategy is available + const hasDownload = !!prebuilt.available; + const canInstallGo = !!info.canInstallGo; + const vendoredReady = !!info.vendoredGoInstalled; + + // Server rebuild is always offered: if no Go and no release, the + // "auto-install Go" path will take over. + const canUpdate = hasGo || hasDownload || canInstallGo; toggleEl.disabled = !canUpdate; - if (!canUpdate) toggleEl.checked = false; - - // Show strategy section when checkbox is enabled + if (strategySection && canUpdate) { strategySection.style.display = ''; - - // Show/enable strategy options based on availability + if (downloadRadio) { downloadRadio.disabled = !hasDownload; if (downloadBadge) { @@ -1452,36 +1461,46 @@ } } } - + if (installGoRadio && installGoBadge) { + installGoRadio.disabled = false; + if (vendoredReady) { + installGoBadge.className = 'badge badge-success badge-sm'; + installGoBadge.textContent = _('updates.toolchain_ready'); + } else { + installGoBadge.className = 'badge badge-info badge-sm'; + installGoBadge.textContent = _('updates.auto_strategy'); + } + } + // Auto-select the best available strategy if (hasDownload && downloadRadio) downloadRadio.checked = true; else if (hasGo && compileRadio) compileRadio.checked = true; + else if (installGoRadio) installGoRadio.checked = true; } - + // Status badge - if (canUpdate) { - if (hasGo && hasDownload) { - statusEl.className = 'badge badge-success badge-sm'; - statusEl.textContent = _('updates.both_available'); - } else if (hasDownload) { - statusEl.className = 'badge badge-info badge-sm'; - statusEl.textContent = _('updates.download_available'); - } else { - statusEl.className = 'badge badge-success badge-sm'; - statusEl.textContent = info.goVersion ? info.goVersion.replace('go version ', '') : 'Go'; - } + if (hasGo && hasDownload) { + statusEl.className = 'badge badge-success badge-sm'; + statusEl.textContent = _('updates.both_available'); + } else if (hasDownload) { + statusEl.className = 'badge badge-info badge-sm'; + statusEl.textContent = _('updates.download_available'); + } else if (hasGo) { + statusEl.className = 'badge badge-success badge-sm'; + statusEl.textContent = info.goVersion ? info.goVersion.replace('go version ', '') : 'Go'; } else { - statusEl.className = 'badge badge-danger badge-sm'; - statusEl.textContent = _('updates.no_method'); + statusEl.className = 'badge badge-info badge-sm'; + statusEl.textContent = _('updates.auto_available'); } - + // Info line if (infoEl) { const parts = []; if (info.binaryPath) parts.push(`Binary: ${info.binaryPath}`); if (info.sourcePresent) parts.push('Source: present'); if (hasDownload && prebuilt.releaseName) parts.push(`Release: ${prebuilt.releaseName}`); - if (!hasGo && !hasDownload) parts.push(_('updates.install_go_hint')); + if (info.goSource && info.goSource !== 'path') parts.push(`Go: ${info.goSource}`); + if (!hasGo && !hasDownload) parts.push(_('updates.toolchain_will_install')); infoEl.textContent = parts.join(' · '); } } catch (_e) { @@ -1491,124 +1510,314 @@ } } + // ---------- Update progress modal ---------- + + const UPDATE_PHASES = [ + { id: 'confirm', icon: 'task_alt', key: 'updates.phase_confirm' }, + { id: 'backup', icon: 'inventory', key: 'updates.phase_backup' }, + { id: 'console', icon: 'cloud_download', key: 'updates.phase_console' }, + { id: 'server', icon: 'memory', key: 'updates.phase_server' }, + { id: 'restart', icon: 'restart_alt', key: 'updates.phase_restart' }, + { id: 'done', icon: 'check_circle', key: 'updates.phase_done' } + ]; + + function buildUpdateModalContent() { + const items = UPDATE_PHASES.map(p => ` +
+ ${p.icon} + ${_(p.key)} + + radio_button_unchecked + +
+ `).join(''); + return ` +
+
+
${items}
+
${_('updates.preparing')}
+

+            
+ `; + } + + function setUpdatePhase(phaseId, state, detail) { + // state: 'pending' | 'active' | 'done' | 'error' | 'skipped' + const stateEl = document.querySelector(`[data-phase-state="${phaseId}"]`); + if (stateEl) { + const icons = { + pending: 'radio_button_unchecked', + active: 'sync', + done: 'check_circle', + error: 'error', + skipped: 'remove_circle_outline' + }; + const cls = { + pending: '', + active: 'spinning', + done: '', + error: '', + skipped: '' + }; + stateEl.innerHTML = `${icons[state] || 'help'}`; + stateEl.dataset.state = state; + } + if (detail) { + const det = document.getElementById('update-modal-detail'); + if (det) det.textContent = detail; + } + // Advance progress bar based on phase index + const idx = UPDATE_PHASES.findIndex(p => p.id === phaseId); + if (idx >= 0) { + const pct = state === 'done' ? Math.round(((idx + 1) / UPDATE_PHASES.length) * 100) + : state === 'active' ? Math.round((idx / UPDATE_PHASES.length) * 100) + : null; + const bar = document.getElementById('update-modal-bar'); + if (bar && pct !== null) bar.style.width = pct + '%'; + } + } + + function logUpdate(line) { + const log = document.getElementById('update-modal-log'); + if (!log) return; + const ts = new Date().toLocaleTimeString(); + log.textContent += `[${ts}] ${line}\n`; + log.scrollTop = log.scrollHeight; + } + async function installUpdate() { const installBtn = document.getElementById('update-install-btn'); - const progressEl = document.getElementById('update-progress'); - const progressFill = document.getElementById('update-progress-fill'); - const progressText = document.getElementById('update-progress-text'); - + if (!_updateState.remoteSHA) { Notifications.error(_('updates.no_version')); return; } - + const includeServer = document.getElementById('update-include-server')?.checked || false; const serverStrategy = includeServer ? (document.querySelector('input[name="server-strategy"]:checked')?.value || 'auto') : null; - const confirmMsg = includeServer - ? _('updates.install_confirm') + '\n\n' + (serverStrategy === 'compile' ? _('updates.server_build_note') : _('updates.server_download_note')) - : _('updates.install_confirm'); - - if (!confirm(confirmMsg)) return; - + const createBackup = document.getElementById('update-backup-toggle')?.checked ?? true; + + // Pre-flight confirmation modal + let strategyNote = ''; + if (includeServer) { + if (serverStrategy === 'compile') strategyNote = _('updates.server_build_note'); + else if (serverStrategy === 'install-go') strategyNote = _('updates.strategy_install_go_hint'); + else if (serverStrategy === 'download') strategyNote = _('updates.server_download_note'); + else strategyNote = _('updates.auto_strategy_hint'); + } + const confirmHtml = ` +

${Utils.escapeHtml(_('updates.install_confirm'))}

+ + `; + const proceed = await new Promise((resolve) => { + window.Modal.show({ + title: _('updates.confirm_title'), + content: confirmHtml, + buttons: [ + { label: _('actions.cancel'), class: 'btn-secondary', onClick: () => { window.Modal.close(); resolve(false); } }, + { label: _('updates.install'), class: 'btn-primary', icon: 'system_update', onClick: () => { window.Modal.close(); resolve(true); } } + ], + closable: true, + onClose: () => resolve(false) + }); + }); + if (!proceed) return; + if (installBtn) installBtn.disabled = true; - if (progressEl) progressEl.style.display = ''; - if (progressFill) progressFill.style.width = '10%'; - if (progressText) progressText.textContent = _('updates.installing'); - + + // Open progress modal (non-closable while running) + window.Modal.show({ + title: _('updates.modal_title'), + content: buildUpdateModalContent(), + buttons: [], + closable: false, + size: 'large' + }); + + UPDATE_PHASES.forEach(p => setUpdatePhase(p.id, 'pending')); + setUpdatePhase('confirm', 'done'); + if (createBackup) setUpdatePhase('backup', 'active', _('updates.creating_backup')); + else setUpdatePhase('backup', 'skipped', _('updates.backup_skipped')); + logUpdate(`Starting update to ${_updateState.remoteSHA.slice(0, 7)}…`); + try { - const createBackup = document.getElementById('update-backup-toggle')?.checked ?? true; const components = ['console', 'scripts']; if (includeServer) components.push('server'); - - if (progressFill) progressFill.style.width = '20%'; - if (progressText) progressText.textContent = createBackup ? _('updates.creating_backup') : _('updates.downloading'); - + + // Console download phase indicator (we cannot stream backend + // progress today, so we just mark it active until response arrives) + setTimeout(() => { + setUpdatePhase('backup', 'done'); + setUpdatePhase('console', 'active', _('updates.downloading')); + }, 800); + if (includeServer) { - // Server build/download can take time — show an informative message + const serverDetailKey = + serverStrategy === 'install-go' ? 'updates.toolchain_downloading' : + serverStrategy === 'compile' ? 'updates.server_building' : + serverStrategy === 'download' ? 'updates.server_downloading' : + 'updates.server_processing'; setTimeout(() => { - if (progressFill && progressFill.style.width !== '100%') { - progressFill.style.width = '50%'; - if (progressText) progressText.textContent = serverStrategy === 'compile' ? _('updates.server_building') : _('updates.server_downloading'); - } - }, 5000); + setUpdatePhase('console', 'done'); + setUpdatePhase('server', 'active', _(serverDetailKey)); + }, 4000); } - + const result = await Utils.api('/api/settings/updates/install', { method: 'POST', body: { remoteSHA: _updateState.remoteSHA, createBackup, components, serverStrategy: serverStrategy || 'auto' } }); - - if (progressFill) progressFill.style.width = '100%'; - - let msg = `${_('updates.applied')}: ${result.applied?.length || 0}`; - if (result.failed?.length) msg += `, ${_('updates.failed')}: ${result.failed.length}`; - if (result.removed?.length) msg += `, ${_('updates.removed')}: ${result.removed.length}`; - - // Server build/deploy results - if (result.serverBuild) { - if (result.serverBuild.success) { - if (result.serverBuild.method === 'download') { - const sizeMB = result.serverBuild.size ? ` (${(result.serverBuild.size / (1024 * 1024)).toFixed(1)} MB)` : ''; - msg += `. ${_('updates.server_downloaded')}${sizeMB}`; + + // Mark earlier phases done if not already + ['backup', 'console'].forEach(p => { + const st = document.querySelector(`[data-phase-state="${p}"]`)?.dataset?.state; + if (st !== 'done' && st !== 'skipped') setUpdatePhase(p, 'done'); + }); + + // Server result + if (includeServer) { + if (result.toolchainInstall) { + if (result.toolchainInstall.success) { + logUpdate(`Go toolchain ready: ${result.toolchainInstall.version || ''}`.trim()); } else { - const secs = Math.round((result.serverBuild.duration || 0) / 1000); - msg += `. ${_('updates.server_built')}` + (secs ? ` (${secs}s)` : ''); + logUpdate(`Go toolchain install failed: ${result.toolchainInstall.error || 'unknown'}`); + } + } + if (result.serverBuild) { + if (result.serverBuild.success) { + const ms = result.serverBuild.duration || 0; + const secs = ms ? Math.round(ms / 1000) : 0; + const sizeMB = result.serverBuild.size ? ` (${(result.serverBuild.size / (1024 * 1024)).toFixed(1)} MB)` : ''; + const detail = result.serverBuild.method === 'download' + ? `${_('updates.server_downloaded')}${sizeMB}` + : `${_('updates.server_built')}${secs ? ` · ${secs}s` : ''}`; + setUpdatePhase('server', 'done', detail); + logUpdate(detail); + } else { + const detail = result.serverBuild.method === 'download' + ? _('updates.server_download_failed') + : _('updates.server_build_failed'); + setUpdatePhase('server', 'error', detail); + logUpdate(`${detail}: ${result.serverBuild.error || ''}`); } } else { - msg += `. ${result.serverBuild.method === 'download' ? _('updates.server_download_failed') : _('updates.server_build_failed')}`; - if (result.serverBuild.error) Notifications.error(result.serverBuild.error); + setUpdatePhase('server', 'skipped', _('updates.server_skipped')); } - } - if (result.serverDeploy) { - if (result.serverDeploy.success) { - msg += `. ${_('updates.server_deployed')}`; - } else { - msg += `. ${_('updates.server_deploy_failed')}`; - if (result.serverDeploy.error) Notifications.error(result.serverDeploy.error); + if (result.serverDeploy && !result.serverDeploy.success) { + logUpdate(`Deploy failed: ${result.serverDeploy.error || ''}`); } - } - - if (result.needsConsoleRestart) { - if (progressText) progressText.textContent = _('updates.restarting'); - Notifications.success(msg); - setTimeout(() => pollServerRestart(), 3000); } else { - Notifications.success(msg); - if (progressEl) setTimeout(() => { progressEl.style.display = 'none'; }, 5000); + setUpdatePhase('server', 'skipped', _('updates.server_not_selected')); + } + + const applied = result.applied?.length || 0; + const failed = result.failed?.length || 0; + const removed = result.removed?.length || 0; + logUpdate(`${_('updates.applied')}: ${applied} · ${_('updates.failed')}: ${failed} · ${_('updates.removed')}: ${removed}`); + + if (result.needsConsoleRestart) { + setUpdatePhase('restart', 'active', _('updates.restarting')); + logUpdate(_('updates.console_will_restart')); + setTimeout(() => pollConsoleRestart(), 2500); + } else { + setUpdatePhase('restart', 'skipped', _('updates.no_restart_needed')); + setUpdatePhase('done', 'done', _('updates.complete')); + showUpdateCompletionModal(result); if (installBtn) installBtn.disabled = false; } } catch (error) { + const activePhase = UPDATE_PHASES.find(p => { + const st = document.querySelector(`[data-phase-state="${p.id}"]`)?.dataset?.state; + return st === 'active'; + }); + if (activePhase) setUpdatePhase(activePhase.id, 'error', error.message || _('updates.install_failed')); + logUpdate(`ERROR: ${error.message || error}`); + + // Replace empty footer with a Close button so the user can dismiss + window.Modal.close(); + await window.Modal.alert({ + title: _('updates.install_failed'), + message: error.message || _('errors.server_error') + }); Notifications.error(error.message || _('updates.install_failed')); - if (progressEl) progressEl.style.display = 'none'; if (installBtn) installBtn.disabled = false; } } - - function pollServerRestart() { - const progressText = document.getElementById('update-progress-text'); + + function showUpdateCompletionModal(result) { + const lines = []; + lines.push(`

${Utils.escapeHtml(_('updates.complete_summary'))}

`); + const stats = [ + { label: _('updates.applied'), value: result.applied?.length || 0 }, + { label: _('updates.failed'), value: result.failed?.length || 0 }, + { label: _('updates.removed'), value: result.removed?.length || 0 } + ]; + lines.push(``); + if (result.serverBuild?.success) { + const note = result.serverBuild.method === 'download' ? _('updates.server_downloaded') : _('updates.server_built'); + lines.push(`

${Utils.escapeHtml(note)}

`); + } + // Some updates require manual reload (e.g., static asset changes) + const needsReload = (result.applied || []).some(p => /\.(js|css|html|ejs)$/i.test(p)); + if (needsReload) { + lines.push(`

${Utils.escapeHtml(_('updates.refresh_recommended'))}

`); + } + + window.Modal.close(); + window.Modal.show({ + title: _('updates.modal_done_title'), + content: lines.join(''), + buttons: [ + { label: _('updates.modal_close'), class: 'btn-secondary', onClick: () => { window.Modal.close(); } }, + { label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: () => { window.location.reload(); } } + ], + closable: true + }); + } + + function pollConsoleRestart() { let attempts = 0; const maxAttempts = 30; - const interval = setInterval(async () => { attempts++; - if (progressText) progressText.textContent = `${_('updates.restarting')} (${attempts}/${maxAttempts})`; - + setUpdatePhase('restart', 'active', `${_('updates.restarting')} (${attempts}/${maxAttempts})`); try { - const resp = await fetch('/api/settings/info', { credentials: 'same-origin' }); + const resp = await fetch('/api/settings/info?_=' + Date.now(), { credentials: 'same-origin' }); if (resp.ok) { clearInterval(interval); - Notifications.success(_('updates.restart_complete')); - setTimeout(() => window.location.reload(), 1000); + setUpdatePhase('restart', 'done', _('updates.restart_complete')); + setUpdatePhase('done', 'done', _('updates.complete')); + logUpdate(_('updates.restart_complete')); + + // Final modal: tell operator to refresh + window.Modal.close(); + window.Modal.show({ + title: _('updates.modal_done_title'), + content: `

${Utils.escapeHtml(_('updates.restart_complete_msg'))}

`, + buttons: [ + { label: _('updates.modal_close'), class: 'btn-secondary', onClick: () => { window.Modal.close(); } }, + { label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: () => { window.location.reload(); } } + ], + closable: true + }); + + // Auto-reload after a short grace period so operators do + // not have to click — gives them time to read the modal. + setTimeout(() => { window.location.reload(); }, 8000); + return; } - } catch { + } catch (_e) { // Server still down, keep polling } - if (attempts >= maxAttempts) { clearInterval(interval); - if (progressText) progressText.textContent = _('updates.restart_timeout'); + setUpdatePhase('restart', 'error', _('updates.restart_timeout')); + logUpdate(_('updates.restart_timeout')); } }, 2000); } diff --git a/web-nodejs/routes/settings.routes.js b/web-nodejs/routes/settings.routes.js index be092141..25a6835e 100644 --- a/web-nodejs/routes/settings.routes.js +++ b/web-nodejs/routes/settings.routes.js @@ -544,6 +544,32 @@ router.get('/api/settings/updates/server-info', requireAuth, requirePermission(' } }); +/** + * POST /api/settings/updates/install-go - Download & install the Go toolchain + * into data/go-toolchain/ so the console can compile the Go server even when + * Go is not present on the host. Idempotent. + */ +router.post('/api/settings/updates/install-go', requireAuth, requirePermission('server.config'), async (req, res) => { + req.setTimeout(600000); + res.setTimeout(600000); + try { + const result = await updateService.installGoToolchain(); + await db.logAction( + req.session?.userId, + 'system_install_go', + result.success ? `Installed Go toolchain (${result.version})` : `Go toolchain install failed: ${result.error}`, + req.ip + ); + if (!result.success) { + return res.status(500).json({ success: false, error: result.error || 'Toolchain install failed' }); + } + res.json({ success: true, data: { version: result.version, binPath: result.binPath } }); + } catch (err) { + console.error('Go toolchain install error:', err); + res.status(500).json({ success: false, error: err.message }); + } +}); + /** * GET /api/settings/updates/check - Check for available updates */ diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js index 3226922d..e33676d6 100644 --- a/web-nodejs/services/updateService.js +++ b/web-nodejs/services/updateService.js @@ -246,15 +246,76 @@ function copyDirRecursive(src, dest) { /** * Check if Go toolchain is available. - * @returns {{ available: boolean, version: string|null }} + * Searches PATH first, then well-known install locations (snap, tarball, + * Homebrew, vendored toolchain). Returns the absolute path so callers can + * `exec` it even when the console process started without a complete PATH. + * + * @returns {{ available: boolean, version: string|null, binPath: string|null, source: string|null }} */ function checkGoAvailable() { + // 1. Try the regular PATH lookup first try { const version = execSync('go version', { timeout: 10000, stdio: 'pipe' }).toString().trim(); - return { available: true, version }; - } catch (_e) { - return { available: false, version: null }; + let binPath = null; + try { + binPath = execSync(IS_WINDOWS ? 'where go' : 'command -v go', { + timeout: 5000, stdio: 'pipe' + }).toString().split(/\r?\n/)[0].trim() || null; + } catch (_e) { /* ok */ } + return { available: true, version, binPath: binPath || 'go', source: 'path' }; + } catch (_e) { /* fall through */ } + + // 2. Scan well-known install locations + const home = process.env.HOME || process.env.USERPROFILE || ''; + const localGoBin = path.join(config.dataDir, 'go-toolchain', 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go'); + const candidates = IS_WINDOWS + ? [ + localGoBin, + 'C:\\Go\\bin\\go.exe', + 'C:\\Program Files\\Go\\bin\\go.exe', + path.join(home, 'go', 'bin', 'go.exe'), + path.join(home, '.local', 'go', 'bin', 'go.exe') + ] + : [ + localGoBin, + '/usr/local/go/bin/go', + '/snap/bin/go', + '/opt/go/bin/go', + '/usr/lib/go/bin/go', + '/usr/lib/go-1.22/bin/go', + '/usr/lib/go-1.23/bin/go', + '/usr/lib/go-1.24/bin/go', + path.join(home, 'go', 'bin', 'go'), + path.join(home, '.local', 'go', 'bin', 'go'), + '/opt/homebrew/bin/go', + '/usr/local/bin/go' + ]; + + for (const candidate of candidates) { + if (!candidate || !fs.existsSync(candidate)) continue; + try { + const version = execSync(`"${candidate}" version`, { + timeout: 10000, stdio: 'pipe' + }).toString().trim(); + const source = candidate === localGoBin ? 'vendored' : 'system'; + return { available: true, version, binPath: candidate, source }; + } catch (_e) { /* candidate broken, try next */ } } + + return { available: false, version: null, binPath: null, source: null }; +} + +/** + * Wrap an exec environment so a manually located `go` binary is on PATH. + */ +function buildEnvWithGo(goBinPath) { + const env = { ...process.env, CGO_ENABLED: '0' }; + if (goBinPath && goBinPath !== 'go') { + const goBinDir = path.dirname(goBinPath); + const sep = IS_WINDOWS ? ';' : ':'; + env.PATH = goBinDir + sep + (env.PATH || ''); + } + return env; } /** @@ -392,31 +453,200 @@ async function buildGoServer() { const binaryName = IS_WINDOWS ? 'betterdesk-server.exe' : 'betterdesk-server'; const outputPath = path.join(serverDir, binaryName); const start = Date.now(); - const buildEnv = { ...process.env, CGO_ENABLED: '0' }; + const goBin = goCheck.binPath || 'go'; + const buildEnv = buildEnvWithGo(goBin); + // Quote when path contains spaces (Windows "Program Files") + const goCmd = /\s/.test(goBin) ? `"${goBin}"` : goBin; try { - await execPromise('go mod download', { + await execPromise(`${goCmd} mod download`, { cwd: serverDir, timeout: 120000, env: buildEnv }); await execPromise( - `go build -trimpath -ldflags="-s -w" -o "${binaryName}" .`, - { cwd: serverDir, timeout: 300000, env: buildEnv } + `${goCmd} build -trimpath -ldflags="-s -w" -o "${binaryName}" .`, + { cwd: serverDir, timeout: 600000, env: buildEnv } ); if (!fs.existsSync(outputPath)) { return { success: false, binaryPath: null, error: 'Build completed but binary not found' }; } - return { success: true, binaryPath: outputPath, duration: Date.now() - start }; + return { success: true, binaryPath: outputPath, duration: Date.now() - start, goVersion: goCheck.version, goSource: goCheck.source }; } catch (err) { const stderr = (err.stderr || '').toString().slice(0, 500); return { success: false, binaryPath: null, error: `Build failed: ${stderr || err.message}`.trim() }; } } +// ---------- Vendored Go toolchain bootstrap ---------- + +const GO_TOOLCHAIN_DIR = path.join(config.dataDir, 'go-toolchain'); +// Minimum Go version required to build the server (must match +// betterdesk-server/go.mod). The actual point release is selected at +// install time from the live go.dev manifest. +const GO_MIN_VERSION = '1.23.0'; + +function getToolchainKey() { + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + if (IS_WINDOWS) return { os: 'windows', arch, kind: 'archive' }; + if (process.platform === 'darwin') return { os: 'darwin', arch, kind: 'archive' }; + return { os: 'linux', arch, kind: 'archive' }; +} + +/** + * Compare semantic-ish Go versions ("go1.23.4" or "1.23.4"). + * Returns >0 if a > b, 0 if equal, <0 if a < b. + */ +function compareGoVersion(a, b) { + const norm = (v) => String(v || '').replace(/^go/, '').split(/[^\d]+/).map(Number).filter(Number.isFinite); + const aa = norm(a), bb = norm(b); + const len = Math.max(aa.length, bb.length); + for (let i = 0; i < len; i++) { + const x = aa[i] || 0, y = bb[i] || 0; + if (x !== y) return x - y; + } + return 0; +} + +/** + * Download a binary file via HTTPS (with up to 5 redirects). + * @returns {Promise} + */ +function httpsDownload(url, redirects = 5) { + return new Promise((resolve, reject) => { + if (redirects < 0) return reject(new Error('Too many redirects')); + const req = https.get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + return resolve(httpsDownload(res.headers.location, redirects - 1)); + } + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + } + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks))); + res.on('error', reject); + }); + req.on('error', reject); + req.setTimeout(600000, () => req.destroy(new Error('Download timeout'))); + }); +} + +/** + * Resolve the latest stable Go release for this OS/arch by querying + * https://go.dev/dl/?mode=json. Returns the asset metadata including + * the canonical SHA-256 sum so the download can be verified securely. + */ +async function resolveGoRelease() { + const key = getToolchainKey(); + const data = await httpsDownload('https://go.dev/dl/?mode=json'); + const releases = JSON.parse(data.toString('utf8')); + if (!Array.isArray(releases) || !releases.length) { + throw new Error('go.dev manifest empty'); + } + // Pick the highest stable release that meets GO_MIN_VERSION. + const stable = releases + .filter(r => r.stable && compareGoVersion(r.version, GO_MIN_VERSION) >= 0) + .sort((a, b) => compareGoVersion(b.version, a.version)); + const target = stable[0] || releases[0]; + const ext = key.os === 'windows' ? 'zip' : 'tar.gz'; + const file = (target.files || []).find(f => + f.os === key.os && f.arch === key.arch && f.kind === 'archive' && f.filename && f.filename.endsWith(ext) + ); + if (!file || !file.sha256 || !file.filename) { + throw new Error(`No Go ${target.version} archive for ${key.os}/${key.arch}`); + } + return { + version: target.version, + filename: file.filename, + sha256: file.sha256, + size: file.size || 0, + url: `https://go.dev/dl/${file.filename}` + }; +} + +/** + * Install (or refresh) the Go toolchain into data/go-toolchain/. + * + * @param {(phase: string, detail?: string) => void} [onProgress] + * @returns {Promise<{ success: boolean, binPath: string|null, version: string|null, error?: string }>} + */ +async function installGoToolchain(onProgress) { + const log = (phase, detail) => { try { (onProgress || (() => {}))(phase, detail); } catch (_e) { /* ignore */ } }; + const goRoot = path.join(GO_TOOLCHAIN_DIR, 'go'); + const goBin = path.join(goRoot, 'bin', IS_WINDOWS ? 'go.exe' : 'go'); + + // Reuse existing install if it still works + if (fs.existsSync(goBin)) { + try { + const v = execSync(`"${goBin}" version`, { timeout: 5000, stdio: 'pipe' }).toString().trim(); + log('ready', v); + return { success: true, binPath: goBin, version: v }; + } catch (_e) { /* fall through and reinstall */ } + } + + fs.mkdirSync(GO_TOOLCHAIN_DIR, { recursive: true }); + + let release; + try { + log('resolving', 'go.dev/dl'); + release = await resolveGoRelease(); + } catch (err) { + return { success: false, binPath: null, version: null, error: `Cannot resolve Go release: ${err.message}` }; + } + + const archivePath = path.join(GO_TOOLCHAIN_DIR, release.filename); + try { + log('downloading', `${release.version} (${Math.round((release.size || 0) / 1048576)} MB)`); + const buf = await httpsDownload(release.url); + + const crypto = require('crypto'); + const sha = crypto.createHash('sha256').update(buf).digest('hex'); + if (sha.toLowerCase() !== release.sha256.toLowerCase()) { + return { + success: false, binPath: null, version: null, + error: `Go toolchain checksum mismatch (expected ${release.sha256.slice(0, 12)}…, got ${sha.slice(0, 12)}…)` + }; + } + fs.writeFileSync(archivePath, buf); + log('extracting', release.filename); + + if (fs.existsSync(goRoot)) { + try { fs.rmSync(goRoot, { recursive: true, force: true }); } catch (_e) { /* ignore */ } + } + + if (IS_WINDOWS) { + await execPromise( + `powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${GO_TOOLCHAIN_DIR.replace(/'/g, "''")}'"`, + { timeout: 300000 } + ); + } else { + await execPromise(`tar -xzf "${archivePath}" -C "${GO_TOOLCHAIN_DIR}"`, { timeout: 300000 }); + } + + try { fs.unlinkSync(archivePath); } catch (_e) { /* ignore */ } + + if (!fs.existsSync(goBin)) { + return { success: false, binPath: null, version: null, error: 'Extraction succeeded but go binary not found' }; + } + if (!IS_WINDOWS) { + try { fs.chmodSync(goBin, 0o755); } catch (_e) { /* ignore */ } + } + + const v = execSync(`"${goBin}" version`, { timeout: 5000, stdio: 'pipe' }).toString().trim(); + log('ready', v); + return { success: true, binPath: goBin, version: v }; + } catch (err) { + try { if (fs.existsSync(archivePath)) fs.unlinkSync(archivePath); } catch (_e) { /* ignore */ } + return { success: false, binPath: null, version: null, error: err.message || String(err) }; + } +} + /** * Deploy the compiled binary to the service installation path. * Creates a timestamped backup of the existing binary first. @@ -579,10 +809,16 @@ function getServerUpdateInfo() { const goInfo = checkGoAvailable(); const binaryPath = detectServerBinaryPath(); const sourcePresent = fs.existsSync(path.join(COMPONENTS.server.localRoot || '', 'go.mod')); + const vendoredGoPath = path.join(GO_TOOLCHAIN_DIR, 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go'); + const vendoredGoInstalled = fs.existsSync(vendoredGoPath); return { goAvailable: goInfo.available, goVersion: goInfo.version, + goPath: goInfo.binPath, + goSource: goInfo.source, // 'path' | 'system' | 'vendored' | null + vendoredGoInstalled, + canInstallGo: true, // toolchain bootstrap is always available binaryPath, sourcePresent, canAutoUpdate: goInfo.available, @@ -889,12 +1125,55 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) { // ---- Server source files + compile/download + deploy ---- if (changedData.grouped.server?.length && selectedComponents.includes('server')) { - const strategy = opts.serverStrategy || 'auto'; // 'auto', 'compile', 'download' - const goAvailable = checkGoAvailable().available; + const strategy = opts.serverStrategy || 'auto'; // 'auto', 'compile', 'download', 'install-go' + let goAvailable = checkGoAvailable().available; let serverBinaryPath = null; let buildUsed = null; - if (strategy === 'compile' || (strategy === 'auto' && goAvailable)) { + // ---- Strategy: download Go toolchain on demand ---- + // Triggered explicitly ('install-go') or by auto-fallback (no Go + no + // matching pre-built binary). + let toolchainInstalled = false; + if (strategy === 'install-go' && !goAvailable) { + const tc = await installGoToolchain(); + results.toolchainInstall = { + success: tc.success, + version: tc.version || null, + error: tc.error || null, + binPath: tc.binPath || null + }; + if (tc.success) { + toolchainInstalled = true; + goAvailable = true; + } + } else if (strategy === 'auto' && !goAvailable) { + // Auto-fallback: only attempt toolchain install if no pre-built + // release is reachable. This keeps the default path light. + try { + const prebuilt = await checkPrebuiltAvailable(); + if (!prebuilt.available || !prebuilt.downloadUrl) { + const tc = await installGoToolchain(); + results.toolchainInstall = { + success: tc.success, + version: tc.version || null, + error: tc.error || null, + binPath: tc.binPath || null, + autoTriggered: true + }; + if (tc.success) { + toolchainInstalled = true; + goAvailable = true; + } + } + } catch (err) { + console.error('[UPDATE] auto-fallback toolchain install failed:', err.message); + } + } + + const wantsCompile = strategy === 'compile' || strategy === 'install-go' || toolchainInstalled + || (strategy === 'auto' && goAvailable); + + if (wantsCompile && goAvailable) { // ---- Strategy: Compile from source ---- // 1. Ensure full source is present (downloads if missing) try { @@ -1209,5 +1488,7 @@ module.exports = { saveLocalSHA, getServerUpdateInfo, getPrebuiltInfo, + installGoToolchain, + checkGoAvailable, COMPONENTS };