Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/update.ts
alexei.dolgolyov 2eeae4a7c1 feat: add release notes overlay with Markdown rendering
- Replace truncated plaintext release notes with full-screen overlay
  rendered via `marked` library
- Server reconnection does a hard page reload instead of custom event
2026-03-25 21:34:59 +03:00

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';
}