feat(updates): auto-install Go toolchain + modal-based update UX

- updateService: enhanced checkGoAvailable() to scan well-known paths
  (/usr/local/go, /snap, vendored data/go-toolchain, /usr/lib/go-1.x)
  and return absolute binary path so spawned exec works without PATH.
- New installGoToolchain(): dynamically fetches go.dev/dl/?mode=json,
  picks newest stable >= 1.23.0 for current OS/arch, downloads archive,
  verifies SHA-256 from manifest, extracts to data/go-toolchain/.
- buildGoServer() now uses absolute go path, 600s timeout.
- New 'install-go' update strategy + auto-fallback in 'auto' mode:
  when host has neither Go nor a prebuilt release, the toolchain is
  installed automatically and compilation proceeds.
- New POST /api/settings/updates/install-go endpoint for explicit
  install (audit-logged, server.config permission gated).
- Settings UI: third 'Auto-install Go and compile' radio. Update
  checkbox is no longer disabled when Go is missing — auto-install
  is always offered as a fallback.
- installUpdate() rewritten as Modal-based phased dialog:
  Confirmation -> Backup -> Console -> Server -> Restart -> Done.
  Live phase icons (pending/active/done/error/skipped), progress bar,
  log pane, completion modal with reload CTA, auto-reload after
  successful console restart.
- New i18n keys (EN+PL): strategy_install_go, auto_strategy,
  toolchain_*, modal_*, phase_*, confirm_*, etc.
- New CSS (.update-progress-modal, .update-phase, .update-progress-log).
This commit is contained in:
UNITRONIX
2026-04-26 01:07:32 +02:00
parent c7f03bbe6f
commit 323e2f1398
6 changed files with 786 additions and 114 deletions
+35 -1
View File
@@ -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"
}
}
+35 -1
View File
@@ -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"
}
}
+88
View File
@@ -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;
+309 -100
View File
@@ -1386,6 +1386,13 @@
<span>${_('updates.strategy_compile')}</span>
<span id="strategy-compile-badge" class="badge badge-sm" style="margin-left:4px;"></span>
</label>
<label style="display:flex;align-items:center;gap:6px;margin:4px 0;cursor:pointer;font-size:13px;">
<input type="radio" name="server-strategy" value="install-go" id="strategy-install-go">
<span class="material-icons" style="font-size:16px;">download_for_offline</span>
<span>${_('updates.strategy_install_go')}</span>
<span id="strategy-install-go-badge" class="badge badge-info badge-sm" style="margin-left:4px;">${_('updates.auto_strategy')}</span>
</label>
<div id="strategy-install-go-hint" class="text-muted" style="font-size:11px;margin-top:4px;">${_('updates.strategy_install_go_hint')}</div>
</div>
<div id="update-server-info" class="text-muted" style="font-size:11px;"></div>
</div>`;
@@ -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 => `
<div class="update-phase" data-phase="${p.id}">
<span class="update-phase-icon material-icons">${p.icon}</span>
<span class="update-phase-label">${_(p.key)}</span>
<span class="update-phase-state" data-phase-state="${p.id}">
<span class="material-icons">radio_button_unchecked</span>
</span>
</div>
`).join('');
return `
<div class="update-progress-modal">
<div class="update-progress-bar"><div class="update-progress-bar-fill" id="update-modal-bar" style="width:0%"></div></div>
<div class="update-phases">${items}</div>
<div class="update-progress-detail" id="update-modal-detail">${_('updates.preparing')}</div>
<pre class="update-progress-log" id="update-modal-log" aria-live="polite"></pre>
</div>
`;
}
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 = `<span class="material-icons ${cls[state] || ''}">${icons[state] || 'help'}</span>`;
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 = `
<p>${Utils.escapeHtml(_('updates.install_confirm'))}</p>
<ul style="margin:8px 0 0 0;padding-left:20px;font-size:13px;color:var(--text-secondary);">
<li>${Utils.escapeHtml(createBackup ? _('updates.confirm_with_backup') : _('updates.confirm_no_backup'))}</li>
${includeServer ? `<li>${Utils.escapeHtml(strategyNote || '')}</li>` : ''}
</ul>
`;
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(`<p>${Utils.escapeHtml(_('updates.complete_summary'))}</p>`);
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(`<ul style="margin:8px 0;padding-left:20px;font-size:13px;">${stats.map(s => `<li>${Utils.escapeHtml(s.label)}: <strong>${s.value}</strong></li>`).join('')}</ul>`);
if (result.serverBuild?.success) {
const note = result.serverBuild.method === 'download' ? _('updates.server_downloaded') : _('updates.server_built');
lines.push(`<p style="font-size:13px;color:var(--text-secondary);">${Utils.escapeHtml(note)}</p>`);
}
// 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(`<p style="font-size:13px;margin-top:8px;">${Utils.escapeHtml(_('updates.refresh_recommended'))}</p>`);
}
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: `<p>${Utils.escapeHtml(_('updates.restart_complete_msg'))}</p>`,
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);
}
+26
View File
@@ -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
*/
+293 -12
View File
@@ -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<Buffer>}
*/
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
};