feat: add auto-update system with release checking, notification UI, and install-type-aware apply
- Abstract ReleaseProvider interface (Gitea impl, swappable for GitHub/GitLab) - Background UpdateService with periodic checks, debounce, dismissed version persistence - Install type detection (installer/portable/docker/dev) with platform-aware asset matching - Download with progress events, silent NSIS reinstall, portable ZIP/tarball swap scripts - Version badge pulse animation, dismissible banner with icon buttons, Settings > Updates tab - Single source of truth: pyproject.toml version via importlib.metadata, CI stamps tag with sed - API: GET/POST status, check, dismiss, apply, GET/PUT settings - i18n: en, ru, zh (27+ keys each)
This commit is contained in:
400
server/src/wled_controller/static/js/features/update.ts
Normal file
400
server/src/wled_controller/static/js/features/update.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// ─── 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 preview
|
||||
const notesEl = document.getElementById('update-release-notes');
|
||||
if (notesEl) {
|
||||
if (status.has_update && status.release && status.release.body) {
|
||||
const truncated = status.release.body.length > 500
|
||||
? status.release.body.slice(0, 500) + '...'
|
||||
: status.release.body;
|
||||
notesEl.textContent = truncated;
|
||||
notesEl.parentElement!.style.display = '';
|
||||
} else {
|
||||
notesEl.textContent = '';
|
||||
notesEl.parentElement!.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user