/** * Auto-update — check for new releases, show banner, manage settings. */ import { fetchWithAuth } from '../core/api.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; import { IconSelect } from '../core/icon-select.ts'; import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts'; // ─── State ────────────────────────────────────────────────── interface UpdateRelease { version: string; tag: string; name: string; body: string; prerelease: boolean; published_at: string; } interface UpdateStatus { current_version: string; has_update: boolean; checking: boolean; last_check: number | null; last_error: string | null; releases_url: string; install_type: string; can_auto_update: boolean; downloading: boolean; download_progress: number; applying: boolean; release: UpdateRelease | null; dismissed_version: string; } let _lastStatus: UpdateStatus | null = null; let _releaseNotesBody = ''; // ─── Version badge highlight ──────────────────────────────── function _setVersionBadgeUpdate(hasUpdate: boolean): void { const badge = document.getElementById('server-version'); if (!badge) return; badge.classList.toggle('has-update', hasUpdate); if (hasUpdate) { badge.style.cursor = 'pointer'; badge.title = t('update.badge_tooltip'); badge.onclick = () => switchSettingsTabToUpdate(); } else { badge.style.cursor = ''; badge.title = ''; badge.onclick = null; } } function switchSettingsTabToUpdate(): void { if (typeof (window as any).openSettingsModal === 'function') { (window as any).openSettingsModal(); } setTimeout(() => { if (typeof (window as any).switchSettingsTab === 'function') { (window as any).switchSettingsTab('updates'); } }, 50); } // ─── Update banner ────────────────────────────────────────── function _showBanner(status: UpdateStatus): void { const release = status.release; if (!release) return; const dismissed = localStorage.getItem('update-dismissed-version'); if (dismissed === release.version) return; const banner = document.getElementById('update-banner'); if (!banner) return; const versionLabel = release.prerelease ? `${release.version} (${t('update.prerelease')})` : release.version; let actions = ''; // "Update Now" button if auto-update is supported if (status.can_auto_update) { actions += ``; } actions += ` ${ICON_EXTERNAL_LINK} `; actions += ``; banner.innerHTML = ` ${t('update.available').replace('{version}', versionLabel)} ${actions} `; banner.style.display = 'flex'; } function _hideBanner(): void { const banner = document.getElementById('update-banner'); if (banner) banner.style.display = 'none'; } export function dismissUpdate(): void { if (!_lastStatus?.release) return; const version = _lastStatus.release.version; localStorage.setItem('update-dismissed-version', version); _hideBanner(); _setVersionBadgeUpdate(false); fetchWithAuth('/system/update/dismiss', { method: 'POST', body: JSON.stringify({ version }), }).catch(() => {}); } // ─── Apply update ─────────────────────────────────────────── export async function applyUpdate(): Promise { if (!_lastStatus?.release) return; const version = _lastStatus.release.version; const confirmed = await showConfirm( t('update.apply_confirm').replace('{version}', version) ); if (!confirmed) return; // Disable the apply button const btns = document.querySelectorAll('.update-banner-apply, #update-apply-btn'); btns.forEach(b => (b as HTMLButtonElement).disabled = true); try { const resp = await fetchWithAuth('/system/update/apply', { method: 'POST', timeout: 600000, // 10 min for download + apply }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } // Server will shut down — the frontend reconnect overlay handles the rest showToast(t('update.applying'), 'info'); } catch (err) { showToast(t('update.apply_error') + ': ' + (err as Error).message, 'error'); btns.forEach(b => (b as HTMLButtonElement).disabled = false); } } // ─── Status fetch (called on page load + WS event) ───────── export async function loadUpdateStatus(): Promise { try { const resp = await fetchWithAuth('/system/update/status'); if (!resp.ok) return; const status: UpdateStatus = await resp.json(); _lastStatus = status; _applyStatus(status); } catch { // silent — non-critical } } function _applyStatus(status: UpdateStatus): void { const dismissed = localStorage.getItem('update-dismissed-version'); const hasVisibleUpdate = status.has_update && status.release != null && status.release.version !== dismissed; _setVersionBadgeUpdate(hasVisibleUpdate); if (hasVisibleUpdate) { _showBanner(status); } else { _hideBanner(); } _renderUpdatePanel(status); } // ─── WS event handlers ───────────────────────────────────── export function initUpdateListener(): void { document.addEventListener('server:update_available', ((e: CustomEvent) => { const data = e.detail; if (data && data.version && !data.dismissed) { loadUpdateStatus(); } }) as EventListener); // Download progress document.addEventListener('server:update_download_progress', ((e: CustomEvent) => { const progress = e.detail?.progress; if (typeof progress === 'number') { _updateProgressBar(progress); } }) as EventListener); } function _updateProgressBar(progress: number): void { const bar = document.getElementById('update-progress-bar'); if (bar) { bar.style.width = `${Math.round(progress * 100)}%`; bar.parentElement!.style.display = progress > 0 && progress < 1 ? '' : 'none'; } } // ─── Manual check ────────────────────────────────────────── export async function checkForUpdates(): Promise { const btn = document.getElementById('update-check-btn') as HTMLButtonElement | null; const spinner = document.getElementById('update-check-spinner'); if (btn) btn.disabled = true; if (spinner) spinner.style.display = ''; try { const resp = await fetchWithAuth('/system/update/check', { method: 'POST' }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } const status: UpdateStatus = await resp.json(); _lastStatus = status; _applyStatus(status); if (status.has_update && status.release) { showToast(t('update.available').replace('{version}', status.release.version), 'info'); } else { showToast(t('update.up_to_date'), 'success'); } } catch (err) { showToast(t('update.check_error') + ': ' + (err as Error).message, 'error'); } finally { if (btn) btn.disabled = false; if (spinner) spinner.style.display = 'none'; } } // ─── Settings panel ──────────────────────────────────────── let _channelIconSelect: IconSelect | null = null; function _getChannelItems(): { value: string; icon: string; label: string; desc: string }[] { return [ { value: 'false', icon: 'S', label: t('update.channel.stable'), desc: t('update.channel.stable_desc'), }, { value: 'true', icon: 'P', label: t('update.channel.prerelease'), desc: t('update.channel.prerelease_desc'), }, ]; } export function initUpdateSettingsPanel(): void { if (!_channelIconSelect) { const sel = document.getElementById('update-channel') as HTMLSelectElement | null; if (sel) { _channelIconSelect = new IconSelect({ target: sel, items: _getChannelItems(), columns: 2, }); } } } export async function loadUpdateSettings(): Promise { try { const resp = await fetchWithAuth('/system/update/settings'); if (!resp.ok) return; const data = await resp.json(); const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null; const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null; const channelEl = document.getElementById('update-channel') as HTMLSelectElement | null; if (enabledEl) enabledEl.checked = data.enabled; if (intervalEl) intervalEl.value = String(data.check_interval_hours); if (_channelIconSelect) { _channelIconSelect.setValue(String(data.include_prerelease)); } else if (channelEl) { channelEl.value = String(data.include_prerelease); } } catch (err) { console.error('Failed to load update settings:', err); } await loadUpdateStatus(); } export async function saveUpdateSettings(): Promise { const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true; const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24'; const check_interval_hours = parseFloat(intervalStr); const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false'; const include_prerelease = channelVal === 'true'; try { const resp = await fetchWithAuth('/system/update/settings', { method: 'PUT', body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } showToast(t('update.settings_saved'), 'success'); } catch (err) { showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error'); } } function _renderUpdatePanel(status: UpdateStatus): void { const currentEl = document.getElementById('update-current-version'); if (currentEl) currentEl.textContent = `v${status.current_version}`; const statusEl = document.getElementById('update-status-text'); if (statusEl) { if (status.has_update && status.release) { statusEl.textContent = t('update.available').replace('{version}', status.release.version); statusEl.style.color = 'var(--warning-color)'; } else if (status.last_error) { statusEl.textContent = t('update.check_error') + ': ' + status.last_error; statusEl.style.color = 'var(--danger-color)'; } else { statusEl.textContent = t('update.up_to_date'); statusEl.style.color = 'var(--primary-color)'; } } const lastCheckEl = document.getElementById('update-last-check'); if (lastCheckEl) { if (status.last_check) { lastCheckEl.textContent = t('update.last_check') + ': ' + new Date(status.last_check * 1000).toLocaleString(); } else { lastCheckEl.textContent = t('update.last_check') + ': ' + t('update.never'); } } // Install type info const installEl = document.getElementById('update-install-type'); if (installEl) { installEl.textContent = t(`update.install_type.${status.install_type}`); } // "Update Now" button in settings panel const applyBtn = document.getElementById('update-apply-btn') as HTMLButtonElement | null; if (applyBtn) { const show = status.has_update && status.can_auto_update; applyBtn.style.display = show ? '' : 'none'; applyBtn.disabled = status.downloading || status.applying; if (status.downloading) { applyBtn.textContent = `${t('update.downloading')} ${Math.round(status.download_progress * 100)}%`; } else if (status.applying) { applyBtn.textContent = t('update.applying'); } else { applyBtn.textContent = t('update.apply_now'); } } // Progress bar const progressBar = document.getElementById('update-progress-bar'); if (progressBar) { const show = status.downloading && status.download_progress > 0 && status.download_progress < 1; progressBar.style.width = `${Math.round(status.download_progress * 100)}%`; progressBar.parentElement!.style.display = show ? '' : 'none'; } // Release notes button visibility const notesGroup = document.getElementById('update-release-notes-group'); if (notesGroup) { if (status.has_update && status.release && status.release.body) { _releaseNotesBody = status.release.body; notesGroup.style.display = ''; } else { _releaseNotesBody = ''; notesGroup.style.display = 'none'; } } } // ─── Release Notes Overlay ───────────────────────────────── export function openReleaseNotes(): void { const overlay = document.getElementById('release-notes-overlay'); const content = document.getElementById('release-notes-content'); if (overlay && content) { import('marked').then(({ marked }) => { content.innerHTML = marked.parse(_releaseNotesBody) as string; overlay.style.display = 'flex'; }); } } export function closeReleaseNotes(): void { const overlay = document.getElementById('release-notes-overlay'); if (overlay) overlay.style.display = 'none'; }