- Replace truncated plaintext release notes with full-screen overlay rendered via `marked` library - Server reconnection does a hard page reload instead of custom event
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
/**
|
|
* 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 += `<button class="btn btn-icon update-banner-action update-banner-apply" onclick="applyUpdate()" title="${t('update.apply_now')}">
|
|
${ICON_DOWNLOAD}
|
|
</button>`;
|
|
}
|
|
|
|
actions += `<a href="${status.releases_url}" target="_blank" rel="noopener" class="btn btn-icon update-banner-action" title="${t('update.view_release')}">
|
|
${ICON_EXTERNAL_LINK}
|
|
</a>`;
|
|
|
|
actions += `<button class="btn btn-icon update-banner-action" onclick="dismissUpdate()" title="${t('update.dismiss')}">
|
|
${ICON_X}
|
|
</button>`;
|
|
|
|
banner.innerHTML = `
|
|
<span class="update-banner-text">
|
|
${t('update.available').replace('{version}', versionLabel)}
|
|
</span>
|
|
${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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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: '<span style="color:#4CAF50;font-weight:700">S</span>',
|
|
label: t('update.channel.stable'),
|
|
desc: t('update.channel.stable_desc'),
|
|
},
|
|
{
|
|
value: 'true',
|
|
icon: '<span style="color:#ff9800;font-weight:700">P</span>',
|
|
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<void> {
|
|
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<void> {
|
|
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';
|
|
}
|