feat(webui): out-of-date detection + one-click update

Surface when LibrePortal is behind upstream and let users update from the
WebUI, reusing the proven git-update path instead of reinventing it.

Detection (host): webuiSystemUpdateCheck writes
frontend/data/system/update_status.json from a throttled git fetch +
behind-count + VERSION compare, off the existing per-minute
`webui generate system` cron. A new /VERSION file is the canonical version.

Display (frontend): update-notifier.js/.css render a global topbar badge
(every page) and a dashboard banner (prominent when behind, subtle "up to
date" with a manual check otherwise), plus a details panel.

Actions go through the task pipeline:
- `libreportal update apply` -> webuiRunUpdate (non-interactive: guards,
  forced check, gitPerformUpdate, then dockerInstallApp libreportal)
- `libreportal update check` -> forced recheck

gitFolderResetAndBackup's body is extracted into gitPerformUpdate (no exit)
so the WebUI path can reuse it; the interactive CLI flow is unchanged.

Detection JSON verified against the repo (up-to-date and behind cases).
webuiRunUpdate's re-clone + redeploy still needs validation on a live host.

The latest-version source is git for now and is the single swap point for
get.libreportal.org later — the JSON contract and frontend stay unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-21 23:33:43 +01:00
parent 75c2e6d9a5
commit d5fe1bc56b
13 changed files with 891 additions and 15 deletions

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.2.0

View File

@ -0,0 +1,237 @@
/* Update Notifier topbar badge, dashboard banner, and details panel.
Driven by js/components/update-notifier.js. Colours come from the active
theme tokens (with safe fallbacks) so it tracks every palette. */
/* ---- Topbar badge -------------------------------------------------------- */
.update-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--status-warning, #e0a106);
border-radius: 6px;
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.14);
color: var(--status-warning, #e0a106);
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
white-space: nowrap;
}
.update-badge:hover {
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.24);
}
.update-badge:active { transform: scale(0.97); }
.update-badge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-warning, #e0a106);
box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6);
animation: update-badge-pulse 2s infinite;
}
@keyframes update-badge-pulse {
0% { box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6); }
70% { box-shadow: 0 0 0 7px rgba(var(--status-warning-rgb, 224, 161, 6), 0); }
100% { box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0); }
}
@media (prefers-reduced-motion: reduce) {
.update-badge-dot { animation: none; }
}
/* ---- Dashboard banner ---------------------------------------------------- */
.update-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 16px 20px;
border: 1px solid var(--status-warning, #e0a106);
border-left-width: 4px;
border-radius: 10px;
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.1);
color: var(--text-primary, #fff);
}
/* Subtle "up to date" / local-install variant of the banner. */
.update-banner.update-banner-ok {
border-color: var(--border-color, rgba(255, 255, 255, 0.12));
border-left-color: var(--status-success, #2ea043);
background: var(--surface-bg, rgba(255, 255, 255, 0.03));
}
.update-banner.update-banner-ok .update-banner-icon { color: var(--status-success, #2ea043); }
.update-banner.update-banner-ok .update-banner-title { font-weight: 600; }
.update-banner-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--status-warning, #e0a106);
}
.update-banner-text { flex: 1 1 auto; min-width: 0; }
.update-banner-title { font-weight: 700; font-size: 1rem; }
.update-banner-sub {
margin-top: 2px;
font-size: 0.85rem;
color: var(--text-muted, #9aa);
}
.update-banner-actions {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
/* ---- Shared action buttons ----------------------------------------------- */
.update-btn-primary,
.update-btn-secondary {
padding: 8px 16px;
border-radius: 6px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
border: 1px solid transparent;
white-space: nowrap;
}
.update-btn-primary {
background: var(--primary-color, #4f7cff);
color: var(--text-on-accent, #fff);
}
.update-btn-primary:hover { background: var(--primary-hover, var(--accent-hover, #3a63d8)); }
.update-btn-secondary {
background: transparent;
border-color: var(--border-color, rgba(255, 255, 255, 0.2));
color: var(--text-primary, #fff);
}
.update-btn-secondary:hover { background: var(--surface-hover, rgba(255, 255, 255, 0.08)); }
/* ---- Details panel (modal) ----------------------------------------------- */
.update-panel-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
.update-panel {
width: 100%;
max-width: 440px;
border: 1px solid var(--card-border, var(--border-color, rgba(255, 255, 255, 0.15)));
border-radius: 12px;
background: var(--card-bg, var(--surface-bg-solid, #1b1f2a));
box-shadow: var(--card-shadow, 0 20px 60px rgba(0, 0, 0, 0.45));
color: var(--text-primary, #fff);
overflow: hidden;
}
.update-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
}
.update-panel-header h3 { margin: 0; font-size: 1.05rem; }
.update-panel-close {
border: none;
background: transparent;
color: var(--text-muted, #9aa);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.update-panel-close:hover { color: var(--text-primary, #fff); }
.update-panel-status {
padding: 14px 20px 0;
font-size: 0.9rem;
color: var(--text-secondary, var(--text-muted, #9aa));
}
.update-panel-status.is-outdated {
color: var(--status-warning, #e0a106);
font-weight: 600;
}
.update-panel-error {
margin: 12px 20px 0;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.82rem;
background: rgba(var(--status-danger-rgb, 220, 53, 69), 0.12);
color: var(--status-danger, #dc3545);
}
.update-panel-rows {
margin: 14px 0 0;
padding: 0 20px;
}
.update-panel-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
font-size: 0.88rem;
}
.update-panel-row:last-child { border-bottom: none; }
.update-panel-row dt { color: var(--text-muted, #9aa); margin: 0; }
.update-panel-row dd {
margin: 0;
font-weight: 600;
text-align: right;
word-break: break-word;
}
.update-panel-note {
margin: 14px 20px 0;
font-size: 0.8rem;
color: var(--text-muted, #9aa);
line-height: 1.45;
}
.update-panel-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 18px 20px 20px;
}
@media (max-width: 600px) {
.update-banner { flex-wrap: wrap; }
.update-banner-actions { width: 100%; }
.update-banner-actions button { flex: 1 1 auto; }
}

View File

@ -32,6 +32,7 @@
<link rel="stylesheet" href="/css/service-buttons.css"> <link rel="stylesheet" href="/css/service-buttons.css">
<link rel="stylesheet" href="/css/dashboard.css"> <link rel="stylesheet" href="/css/dashboard.css">
<link rel="stylesheet" href="/css/tasks.css"> <link rel="stylesheet" href="/css/tasks.css">
<link rel="stylesheet" href="/css/update-notifier.css">
<script> <script>
// Inline data-theme bootstrap — runs before any rendering so the right // Inline data-theme bootstrap — runs before any rendering so the right
// palette tokens resolve on first paint. Synchronously injects a // palette tokens resolve on first paint. Synchronously injects a

View File

@ -875,6 +875,10 @@ class TasksManager {
if (/^libreportal setup finalize\b/.test(task.command)) return 'LibrePortal - Finalize Setup'; if (/^libreportal setup finalize\b/.test(task.command)) return 'LibrePortal - Finalize Setup';
if (/^libreportal setup apply\b/.test(task.command)) return 'LibrePortal - Setup Wizard'; if (/^libreportal setup apply\b/.test(task.command)) return 'LibrePortal - Setup Wizard';
// Self-update actions (WebUI "Update now" / "Check for updates").
if (/^libreportal update (apply|now)\b/.test(task.command)) return 'LibrePortal - Update';
if (/^libreportal update check\b/.test(task.command)) return 'LibrePortal - Check for Updates';
// Backup engine — per-app actions. // Backup engine — per-app actions.
const backupDeleteAllMatch = task.command.match(/libreportal backup app delete_all (\w+)/); const backupDeleteAllMatch = task.command.match(/libreportal backup app delete_all (\w+)/);
if (backupDeleteAllMatch) return `${displayName(backupDeleteAllMatch[1])} - Delete All Backups`; if (backupDeleteAllMatch) return `${displayName(backupDeleteAllMatch[1])} - Delete All Backups`;
@ -998,7 +1002,7 @@ class TasksManager {
the row can show the LibrePortal logo instead of a blank icon slot. */ the row can show the LibrePortal logo instead of a blank icon slot. */
isLibrePortalSystemTask(task) { isLibrePortalSystemTask(task) {
if (!task || !task.command || task.app) return false; if (!task || !task.command || task.app) return false;
return /^libreportal (setup|backup\s+all|backup\s+verify|backup\s+location|backup\s+repo|restore\s+migrate\s+system|restore\s+migrate\s+discover|restore\s+first-run|webui|config)\b/.test(task.command); return /^libreportal (setup|backup\s+all|backup\s+verify|backup\s+location|backup\s+repo|restore\s+migrate\s+system|restore\s+migrate\s+discover|restore\s+first-run|webui|config|update)\b/.test(task.command);
} }
/* Render the leading icon(s) on a task row: /* Render the leading icon(s) on a task row:

View File

@ -70,6 +70,8 @@ class TopbarComponent {
this.setupLogout(); this.setupLogout();
this.setupConfigUpdateLockout(); this.setupConfigUpdateLockout();
this.setupSetupGate(); this.setupSetupGate();
// Mount the "out of date" badge now that .topbar-controls exists.
window.updateNotifier?.onTopbarReady();
} }
// Disable nav items entirely until the Setup Wizard has been completed. // Disable nav items entirely until the Setup Wizard has been completed.

View File

@ -0,0 +1,351 @@
// Update Notifier
// -----------------------------------------------------------------------------
// Surfaces LibrePortal's "out of date" state across the WebUI:
// * a persistent badge in the global topbar (visible on every page), and
// * a banner on the dashboard,
// both driven by /data/system/update_status.json (written host-side by
// webuiSystemUpdateCheck — see scripts/webui/data/generators/system/).
//
// The two actions both go through the normal task pipeline so the user can
// watch them stream on the Tasks page, exactly like an app install:
// * "Update now" -> task `libreportal update apply`
// * "Check for updates" -> task `libreportal update check`
//
// This file owns no detection logic of its own. When get.libreportal.org is
// wired up, only the host-side generator changes; this stays as-is.
class UpdateNotifier {
constructor() {
this.status = null;
this.fetching = null; // de-dupe concurrent fetches
this.pollMs = 5 * 60 * 1000; // re-read the status file every 5 min
this.pollTimer = null;
this.started = false;
}
// ---- data ----------------------------------------------------------------
async fetchStatus() {
if (this.fetching) return this.fetching;
this.fetching = (async () => {
try {
const res = await fetch('/data/system/update_status.json', { cache: 'no-store' });
if (!res.ok) return null;
this.status = await res.json();
return this.status;
} catch {
return null;
} finally {
this.fetching = null;
}
})();
return this.fetching;
}
async refresh() {
await this.fetchStatus();
this.renderTopbarBadge();
this.renderDashboardBanner();
}
// ---- lifecycle -----------------------------------------------------------
start() {
if (this.started) return;
this.started = true;
this.refresh();
// The topbar HTML and this script load independently; if the topbar
// mounted first, the authoritative onTopbarReady() call already no-op'd.
// Briefly retry until .topbar-controls exists so the badge appears on
// first load regardless of which won the race.
let tries = 0;
const ensure = setInterval(() => {
if (document.querySelector('.topbar-controls')) { this.renderTopbarBadge(); clearInterval(ensure); }
else if (++tries > 30) clearInterval(ensure); // ~15s ceiling
}, 500);
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => this.refresh(), this.pollMs);
// Re-read the status as soon as an update/check task finishes so the badge
// clears (or the version updates) without waiting for the next poll.
const onTask = (event) => {
const cmd = event?.detail?.command || event?.detail?.task?.command || '';
const action = event?.detail?.action;
if (/^libreportal update\b/.test(cmd) || action === 'update') {
// Give the host a beat to finish writing update_status.json.
setTimeout(() => this.refresh(), 1500);
}
};
window.addEventListener('taskCompleted', onTask);
window.addEventListener('taskUpdated', onTask);
}
// Called by TopbarComponent.init() once the topbar DOM exists.
onTopbarReady() {
this.renderTopbarBadge();
this.refresh();
}
// ---- topbar badge --------------------------------------------------------
renderTopbarBadge() {
const controls = document.querySelector('.topbar-controls');
if (!controls) return;
let badge = document.getElementById('update-badge');
const show = this.status && this.status.update_available === true;
if (!show) {
if (badge) badge.remove();
return;
}
if (!badge) {
badge = document.createElement('button');
badge.id = 'update-badge';
badge.className = 'update-badge';
badge.type = 'button';
badge.addEventListener('click', () => this.openPanel());
controls.insertBefore(badge, controls.firstChild);
}
const to = this._versionLabel();
badge.title = `Update available${to ? ' — ' + to : ''}`;
badge.setAttribute('aria-label', badge.title);
badge.innerHTML = `
<span class="update-badge-dot" aria-hidden="true"></span>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
<span class="update-badge-text">Update</span>`;
}
// ---- dashboard banner ----------------------------------------------------
renderDashboardBanner() {
const main = document.querySelector('.dashboard-main');
if (!main) return; // not on the dashboard
let banner = document.getElementById('update-banner');
const s = this.status;
// No status file yet (fresh install before the first check) — show nothing.
if (!s) {
if (banner) banner.remove();
return;
}
if (!banner) {
banner = document.createElement('div');
banner.id = 'update-banner';
main.insertBefore(banner, main.firstChild);
}
const refreshIcon = `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>`;
const checkIcon = `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>`;
if (s.update_available) {
// Prominent: an update is waiting.
const versionLine = this._versionLabel() || `${s.behind} update${s.behind === 1 ? '' : 's'} behind`;
const behindLine = s.behind > 0 ? `${s.behind} commit${s.behind === 1 ? '' : 's'} behind` : '';
banner.className = 'update-banner';
banner.innerHTML = `
<div class="update-banner-icon" aria-hidden="true">${refreshIcon}</div>
<div class="update-banner-text">
<div class="update-banner-title">A LibrePortal update is available</div>
<div class="update-banner-sub">${this._escape(versionLine)}${behindLine ? ` &middot; ${this._escape(behindLine)}` : ''}</div>
</div>
<div class="update-banner-actions">
<button type="button" class="update-btn-secondary" id="update-banner-details">Details</button>
${s.can_update ? '<button type="button" class="update-btn-primary" id="update-banner-update">Update now</button>' : ''}
</div>`;
} else {
// Subtle: up to date (or a local install). Still gives a version readout
// and a manual "Check for updates" entry point.
const local = s.source === 'local' || s.install_mode === 'local';
const verText = (s.current_version && s.current_version !== 'unknown') ? `LibrePortal v${s.current_version}` : 'LibrePortal';
const subText = local ? 'Local installation — updates are managed manually' : 'Up to date';
banner.className = 'update-banner update-banner-ok';
banner.innerHTML = `
<div class="update-banner-icon" aria-hidden="true">${checkIcon}</div>
<div class="update-banner-text">
<div class="update-banner-title">${this._escape(verText)}</div>
<div class="update-banner-sub">${this._escape(subText)}</div>
</div>
<div class="update-banner-actions">
<button type="button" class="update-btn-secondary" id="update-banner-details">Details</button>
${local ? '' : '<button type="button" class="update-btn-secondary" id="update-banner-check">Check for updates</button>'}
</div>`;
}
const details = banner.querySelector('#update-banner-details');
if (details) details.addEventListener('click', () => this.openPanel());
const updateBtn = banner.querySelector('#update-banner-update');
if (updateBtn) updateBtn.addEventListener('click', () => this.runUpdate());
const checkBtn = banner.querySelector('#update-banner-check');
if (checkBtn) checkBtn.addEventListener('click', () => this.checkNow());
}
// ---- details panel (self-contained modal) --------------------------------
openPanel() {
this.closePanel();
const s = this.status || {};
const overlay = document.createElement('div');
overlay.id = 'update-panel-overlay';
overlay.className = 'update-panel-overlay';
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.closePanel(); });
const local = s.source === 'local' || s.install_mode === 'local';
const rows = [
['Installed version', s.current_version || 'unknown'],
['Latest version', s.latest_version || 'unknown'],
['Commits behind', (s.behind ?? 0).toString()],
['Branch', s.branch || '—'],
['Last checked', this._formatTime(s.checked_at)],
];
const statusLine = local
? 'This is a local installation — updates are managed manually on the host.'
: (s.update_available
? 'An update is available.'
: 'LibrePortal is up to date.');
overlay.innerHTML = `
<div class="update-panel" role="dialog" aria-modal="true" aria-label="LibrePortal updates">
<div class="update-panel-header">
<h3>LibrePortal Updates</h3>
<button type="button" class="update-panel-close" id="update-panel-close" aria-label="Close">&times;</button>
</div>
<div class="update-panel-status ${s.update_available ? 'is-outdated' : ''}">${this._escape(statusLine)}</div>
${s.error ? `<div class="update-panel-error">${this._escape(s.error)}</div>` : ''}
<dl class="update-panel-rows">
${rows.map(([k, v]) => `<div class="update-panel-row"><dt>${this._escape(k)}</dt><dd>${this._escape(v)}</dd></div>`).join('')}
</dl>
${s.can_update && s.update_available ? '<p class="update-panel-note">Updating backs up your configuration, pulls the latest version, and restarts LibrePortal. Progress streams on the Tasks page.</p>' : ''}
<div class="update-panel-actions">
<button type="button" class="update-btn-secondary" id="update-panel-check">Check for updates</button>
${s.can_update && s.update_available ? '<button type="button" class="update-btn-primary" id="update-panel-update">Update now</button>' : ''}
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#update-panel-close').addEventListener('click', () => this.closePanel());
overlay.querySelector('#update-panel-check').addEventListener('click', () => this.checkNow());
const upd = overlay.querySelector('#update-panel-update');
if (upd) upd.addEventListener('click', () => this.runUpdate());
this._escHandler = (e) => { if (e.key === 'Escape') this.closePanel(); };
document.addEventListener('keydown', this._escHandler);
}
closePanel() {
const overlay = document.getElementById('update-panel-overlay');
if (overlay) overlay.remove();
if (this._escHandler) {
document.removeEventListener('keydown', this._escHandler);
this._escHandler = null;
}
}
// ---- actions -------------------------------------------------------------
async runUpdate() {
this.closePanel();
try {
await this._createTask('libreportal update apply');
this._toast('LibrePortal update started — follow progress on the Tasks page.', 'info');
this._goToTasks();
} catch (e) {
this._toast('Could not start the update: ' + e.message, 'error');
}
}
async checkNow() {
try {
await this._createTask('libreportal update check');
this._toast('Checking for updates…', 'info');
// The check task rewrites update_status.json; refresh shortly after.
setTimeout(() => this.refresh(), 4000);
} catch (e) {
this._toast('Could not check for updates: ' + e.message, 'error');
}
}
// ---- helpers -------------------------------------------------------------
async _createTask(command) {
if (window.tasksManager?.taskManager?.createTask) {
return window.tasksManager.taskManager.createTask(command, 'update', null, '');
}
if (typeof TaskManager !== 'undefined') {
return new TaskManager().createTask(command, 'update', null, '');
}
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, type: 'update', app: null, config: '' })
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
_goToTasks() {
if (window.librePortalSPA?.navigate) window.librePortalSPA.navigate('/tasks');
else if (typeof navigateToRoute === 'function') navigateToRoute('/tasks');
else window.location.href = '/tasks';
}
_toast(message, type = 'info') {
const ns = window.notificationSystem || window.ensureNotificationSystem?.();
if (ns?.show) ns.show(message, type);
else console.log(`[update] ${message}`);
}
_versionLabel() {
const s = this.status;
if (!s) return '';
const cur = s.current_version, latest = s.latest_version;
if (cur && latest && cur !== latest && latest !== 'unknown') return `v${cur} → v${latest}`;
if (latest && latest !== 'unknown') return `v${latest}`;
return '';
}
_formatTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleString();
}
_escape(str) {
return String(str).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
window.updateNotifier = window.updateNotifier || new UpdateNotifier();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => window.updateNotifier.start());
} else {
window.updateNotifier.start();
}

View File

@ -70,6 +70,7 @@ class SystemLoader {
dependencies: [], dependencies: [],
scripts: [ scripts: [
'/js/components/topbar.js', '/js/components/topbar.js',
'/js/components/update-notifier.js',
'/js/components/mobile-menu.js' '/js/components/mobile-menu.js'
] ]
}); });

View File

@ -212,6 +212,9 @@ async function loadDashboardData() {
// Start countdown to next automatic update // Start countdown to next automatic update
startUpdateCountdown(); startUpdateCountdown();
// Show/refresh the "out of date" banner on the dashboard.
window.updateNotifier?.renderDashboardBanner();
// console.log('✅ Dashboard data loaded successfully'); // console.log('✅ Dashboard data loaded successfully');
} }

View File

@ -7,10 +7,24 @@ cliHandleUpdateCommands()
{ {
local update_type="$initial_command2" local update_type="$initial_command2"
if [[ -z "$update_type" ]]; then case "$update_type" in
checkUpdates; "")
else # Interactive CLI updater (prompts the user).
checkUpdates
;;
"check")
# Non-interactive: force a fresh out-of-date check and rewrite
# update_status.json. Used by the WebUI "Check for updates" action.
webuiSystemUpdateCheck "force"
;;
"apply"|"now")
# Non-interactive: perform the update. Used by the WebUI
# "Update now" action.
webuiRunUpdate
;;
*)
isNotice "Invalid update command: ${RED}$update_type${NC}" isNotice "Invalid update command: ${RED}$update_type${NC}"
cliShowUpdateHelp; cliShowUpdateHelp
fi ;;
esac
} }

View File

@ -8,9 +8,12 @@ cliShowUpdateHelp()
echo "" echo ""
echo "Available Update Commands:" echo "Available Update Commands:"
echo "" echo ""
echo " libreportal update - Updates LibrePortal to the latest version" echo " libreportal update - Interactively check for and install updates"
echo " libreportal update check - Refresh the out-of-date status (no install)"
echo " libreportal update apply - Non-interactively update to the latest version"
echo "" echo ""
echo "This command will check for and install the latest LibrePortal updates." echo "These commands check for and install the latest LibrePortal updates."
echo "It will backup your current configuration before updating." echo "Your current configuration is backed up before updating."
echo "'check' and 'apply' are also what the WebUI Update notification uses."
echo "" echo ""
} }

View File

@ -1,6 +1,16 @@
#!/bin/bash #!/bin/bash
gitFolderResetAndBackup() # gitPerformUpdate — the proven, file-level LibrePortal update.
#
# Backs up the user's configs/logs, re-clones the repo fresh (gitReset), then
# restores the configs/logs over the clean checkout, zips a snapshot, prunes
# old backups, and stops tracking ignored files. This is the historical update
# body that "worked great" — extracted out of gitFolderResetAndBackup so it can
# be reused non-interactively by the WebUI updater (webuiRunUpdate) WITHOUT the
# trailing `exit` the interactive CLI flow relies on.
#
# Does NOT exit, restart, or redeploy — the caller decides what happens next.
gitPerformUpdate()
{ {
isHeader "Updating LibrePortal" isHeader "Updating LibrePortal"
update_done=false update_done=false
@ -28,6 +38,11 @@ gitFolderResetAndBackup()
gitCleanInstallBackups; gitCleanInstallBackups;
gitUntrackFiles; gitUntrackFiles;
}
gitFolderResetAndBackup()
{
gitPerformUpdate;
isSuccessful "Custom changes have been discarded successfully" isSuccessful "Custom changes have been discarded successfully"
echo "" echo ""

View File

@ -1,5 +1,86 @@
#!/bin/bash #!/bin/bash
# webuiRunUpdate — non-interactive LibrePortal update for the WebUI.
#
# Invoked as a task via `libreportal update apply` (see cli_update_commands.sh),
# so it runs under the task processor with stdin closed and
# LIBREPORTAL_NONINTERACTIVE=1. It must therefore never block on a prompt:
# every decision is resolved up front from config, and it bails out cleanly
# (non-zero) instead of asking a question.
#
# Flow: guard -> forced update check -> if behind, run the proven file update
# (gitPerformUpdate) -> redeploy the portal so the new WebUI is live ->
# regenerate WebUI data and refresh the out-of-date status.
webuiRunUpdate()
{
isHeader "LibrePortal Update"
sourceCheckFiles;
local install_mode="${CFG_INSTALL_MODE:-git}"
local git_updates="${CFG_GIT_UPDATES:-true}"
if [[ "$install_mode" == "local" ]]; then
isError "This is a local installation — updates are managed manually, nothing to pull."
return 1
fi
if [[ "$git_updates" != "true" ]]; then
isError "Git updates are disabled (CFG_GIT_UPDATES). Enable them to update from the WebUI."
return 1
fi
# Credential guard — gitReset()/gitCheckGitDetails() would otherwise prompt
# for missing git details and hang the task forever. Fail fast with a clear
# message instead.
if [[ "$install_mode" == "git" ]]; then
if [[ -z "$CFG_GIT_USER" || "$CFG_GIT_USER" == "changeme" ]]; then
isError "Git credentials are not configured. Run the 'libreportal' command once on the host, then retry."
return 1
fi
if [[ "$CFG_GIT_USER" != "empty" && ( -z "$CFG_GIT_KEY" || "$CFG_GIT_KEY" == "changeme" ) ]]; then
isError "Git access token is not configured. Run the 'libreportal' command once on the host, then retry."
return 1
fi
fi
cd "$script_dir" || { isError "Cannot access the install directory ($script_dir)."; return 1; }
sudo -u "$sudo_user_name" git config core.fileMode false
# Force a fresh fetch + status write so the decision below (and the badge)
# reflect reality right now, not a stale throttled snapshot.
webuiSystemUpdateCheck "force"
local branch behind
branch=$(sudo -u "$sudo_user_name" git -C "$script_dir" rev-parse --abbrev-ref HEAD 2>/dev/null)
[[ -z "$branch" || "$branch" == "HEAD" ]] && branch="main"
behind=$(sudo -u "$sudo_user_name" git -C "$script_dir" rev-list --count "HEAD..refs/remotes/origin/$branch" 2>/dev/null)
[[ -z "$behind" ]] && behind=0
if [[ "$behind" -eq 0 ]]; then
isSuccessful "LibrePortal is already up to date."
return 0
fi
isNotice "Update found — $behind commit(s) behind origin/$branch. Updating now..."
# Proven file-level update: back up configs/logs, re-clone, restore.
gitPerformUpdate;
# Redeploy the portal from the freshly pulled source so the new WebUI goes
# live. This is exactly what `libreportal app install libreportal` does, so
# it's already safe to run non-interactively. It restarts the container; the
# task processor runs on the host, so this task survives the restart.
isNotice "Redeploying LibrePortal with the new version..."
dockerInstallApp "libreportal"
# Regenerate WebUI data (new version, configs, etc.) and clear the
# out-of-date flag.
WEBUI_UPDATER_FORCE=1 webuiLibrePortalUpdate
webuiSystemUpdateCheck "force"
isSuccessful "LibrePortal has been updated."
}
checkUpdates() checkUpdates()
{ {
local param1="$1" local param1="$1"

View File

@ -7,5 +7,168 @@ webuiSystemUpdate() {
webuiSystemInfo webuiSystemInfo
webuiSystemDisk webuiSystemDisk
webuiSystemMemory webuiSystemMemory
webuiSystemUpdateCheck
isSuccessful "System information updated!" isSuccessful "System information updated!"
} }
# ---------------------------------------------------------------------------
# WebUI "Out of Date" detection
# ---------------------------------------------------------------------------
# Writes frontend/data/system/update_status.json so the dashboard + topbar can
# tell the user when their LibrePortal install is behind the upstream release.
#
# Detection mirrors the model the CLI updater already trusts (scripts/update):
# a git working copy at $script_dir tracking a remote branch. We `git fetch`
# (throttled — the network call is the expensive part) and compare the local
# HEAD to origin/<branch>. The local VERSION file vs the remote VERSION file
# turns the raw "N commits behind" into a friendly "v0.1.0 -> v0.2.0".
#
# The JSON is deliberately source-agnostic: when get.libreportal.org is ready
# in the website session, only the `latest_version` / `latest_commit` /
# `behind` values need to come from the HTTP endpoint instead of git — the
# frontend and the rest of this file stay the same.
#
# Pass "force" as $1 to bypass the fetch throttle (used by the manual
# "Check for updates" action: `libreportal update check`).
webuiSystemUpdateCheck() {
local force_flag="$1"
local repo_dir="${script_dir}"
local system_dir="$containers_dir/libreportal/frontend/data/system"
local final_file="${system_dir}/update_status.json"
local stamp_file="${system_dir}/.update_check_stamp"
# How long (seconds) a fetch result stays "fresh" before we hit the network
# again. Overridable via CFG_UPDATE_CHECK_INTERVAL; defaults to 3 hours.
local fetch_interval="${CFG_UPDATE_CHECK_INTERVAL:-10800}"
createFolders "quiet" "$sudo_user_name" "$system_dir"
local install_mode="${CFG_INSTALL_MODE:-git}"
local git_updates="${CFG_GIT_UPDATES:-true}"
local auto_updates="${CFG_GIT_AUTO_UPDATES:-false}"
# Local version: VERSION file first, fall back to the latest reachable tag.
local current_version=""
if [[ -f "$repo_dir/VERSION" ]]; then
current_version=$(tr -d ' \t\n\r' < "$repo_dir/VERSION")
fi
if [[ -z "$current_version" ]]; then
current_version=$(sudo -u "$sudo_user_name" git -C "$repo_dir" describe --tags --abbrev=0 2>/dev/null)
fi
[[ -z "$current_version" ]] && current_version="unknown"
# Atomic JSON writer. Args (positional):
# 1 update_available 2 can_update 3 current_version 4 latest_version
# 5 current_commit 6 latest_commit 7 behind 8 ahead 9 branch
# 10 source 11 error_message (empty = null)
_webuiWriteUpdateStatus() {
local _update_available="$1" _can_update="$2"
local _current_version="$3" _latest_version="$4"
local _current_commit="$5" _latest_commit="$6"
local _behind="$7" _ahead="$8" _branch="$9" _source="${10}" _error="${11}"
local _error_json="null"
[[ -n "$_error" ]] && _error_json="\"$_error\""
local temp_file="${final_file}.tmp.$$"
cat << EOF > "$temp_file"
{
"update_available": ${_update_available},
"can_update": ${_can_update},
"current_version": "${_current_version}",
"latest_version": "${_latest_version}",
"current_commit": "${_current_commit}",
"latest_commit": "${_latest_commit}",
"behind": ${_behind},
"ahead": ${_ahead},
"branch": "${_branch}",
"install_mode": "${install_mode}",
"git_updates_enabled": ${git_updates},
"auto_updates": ${auto_updates},
"source": "${_source}",
"error": ${_error_json},
"checked_at": "$(date -Iseconds)"
}
EOF
if [ $? -eq 0 ]; then
mv "$temp_file" "$final_file"
createTouch "$final_file" "$sudo_user_name"
else
rm -f "$temp_file" 2>/dev/null
fi
}
# Not a git working copy, or a deliberately local install: we can't compare
# against an upstream, so report "managed manually" rather than an error.
if [[ ! -d "$repo_dir/.git" || "$install_mode" == "local" ]]; then
_webuiWriteUpdateStatus "false" "false" \
"$current_version" "$current_version" \
"" "" "0" "0" "" "local" ""
return 0
fi
local branch
branch=$(sudo -u "$sudo_user_name" git -C "$repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null)
[[ -z "$branch" || "$branch" == "HEAD" ]] && branch="main"
sudo -u "$sudo_user_name" git -C "$repo_dir" config core.fileMode false >/dev/null 2>&1
# Decide whether to hit the network this run.
local do_fetch="false"
if [[ "$force_flag" == "force" || ! -f "$stamp_file" ]]; then
do_fetch="true"
else
local _now _last
_now=$(date +%s)
_last=$(stat -c '%Y' "$stamp_file" 2>/dev/null || echo 0)
(( _now - _last >= fetch_interval )) && do_fetch="true"
fi
local fetch_error=""
if [[ "$do_fetch" == "true" ]]; then
local _fetched="false"
if [[ "$install_mode" == "git" && -n "$CFG_GIT_USER" && "$CFG_GIT_USER" != "empty" && "$CFG_GIT_USER" != "changeme" ]]; then
if sudo -u "$sudo_user_name" git -C "$repo_dir" \
-c "credential.helper=" \
-c "credential.helper=!f() { echo username=$CFG_GIT_USER; echo password=$CFG_GIT_KEY; }; f" \
fetch --quiet origin "$branch" >/dev/null 2>&1; then
_fetched="true"
fi
else
if sudo -u "$sudo_user_name" git -C "$repo_dir" fetch --quiet origin "$branch" >/dev/null 2>&1; then
_fetched="true"
fi
fi
if [[ "$_fetched" == "true" ]]; then
sudo -u "$sudo_user_name" touch "$stamp_file" 2>/dev/null || touch "$stamp_file" 2>/dev/null
else
fetch_error="Could not reach the update server."
fi
fi
# Compare local HEAD against the (possibly just-fetched) remote ref.
local current_commit latest_commit behind ahead latest_version
current_commit=$(sudo -u "$sudo_user_name" git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null)
latest_commit=$(sudo -u "$sudo_user_name" git -C "$repo_dir" rev-parse --short "refs/remotes/origin/$branch" 2>/dev/null)
behind=$(sudo -u "$sudo_user_name" git -C "$repo_dir" rev-list --count "HEAD..refs/remotes/origin/$branch" 2>/dev/null)
ahead=$(sudo -u "$sudo_user_name" git -C "$repo_dir" rev-list --count "refs/remotes/origin/$branch..HEAD" 2>/dev/null)
[[ -z "$behind" ]] && behind=0
[[ -z "$ahead" ]] && ahead=0
[[ -z "$current_commit" ]] && current_commit="unknown"
[[ -z "$latest_commit" ]] && latest_commit="$current_commit"
latest_version=$(sudo -u "$sudo_user_name" git -C "$repo_dir" show "refs/remotes/origin/$branch:VERSION" 2>/dev/null | tr -d ' \t\n\r')
[[ -z "$latest_version" ]] && latest_version="$current_version"
local update_available="false"
[[ "$behind" -gt 0 ]] && update_available="true"
local can_update="false"
[[ "$install_mode" == "git" && "$git_updates" == "true" ]] && can_update="true"
_webuiWriteUpdateStatus "$update_available" "$can_update" \
"$current_version" "$latest_version" \
"$current_commit" "$latest_commit" \
"$behind" "$ahead" "$branch" "git" "$fetch_error"
}