Compare commits

...

10 Commits

Author SHA1 Message Date
librelad
b17ac3707e fix(webui): refresh stale per-port Subdomain tooltip
The Subdomain field's help text still said it inherits CFG_HOST_NAME and that
the label-generation refactor was pending — both untrue now that per-port
subdomain routing has shipped. Reword to: empty -> app-name default, @ ->
domain apex, multi-level supported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 11:16:16 +01:00
librelad
36e0d31385 Merge branch 'claude/2' into main
- Data-driven Eleventy marketing site (site/)
- HOST_NAME honoured for subdomains + @ apex hosting
- Dynamic per-port subdomains with router-block toggle (all apps converted)
- Split-horizon local DNS (AdGuard wildcard + Pi-hole hosts)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 10:59:48 +01:00
librelad
79a1ec4cc3 fix(install): resolve installer function name case-insensitively
dockerInstallApp built the installer name by upper-casing only the first
letter of the slug (libreportal -> installLibreportal), which can't match
camelCase installers like installLibrePortal. After the EasyDocker ->
LibrePortal rename this broke `libreportal` installs with
"installLibreportal: command not found".

If the naive name isn't a defined function, resolve it case-insensitively
against the function table (compgen -A function), and fail with a clear
message if nothing matches. Works for any compound brand/app name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:34:26 +01:00
librelad
7ec1e33b56 style(branding): drop the divider line under the logo
Keep just the wordmark + portal; the underline read poorly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:34:14 +01:00
librelad
0c7eac89fc style(branding): revert divider to low marks, keep top blank line
The raised (‾▔) divider read strangely; go back to the low _▁ step-ticks
the prior look used and restore the leading blank line. Keep the divider
extended to the end of the final letter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:26:53 +01:00
librelad
77d9cf9809 style(branding): raise divider, extend to last glyph, drop top blank line
Raise the underline to high marks (‾▔) so it tucks under the wordmark,
extend it to reach the end of the final letter, and remove the leading
blank line so the banner starts flush.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:24:34 +01:00
librelad
d6b6b1ef8a style(branding): indent logo + add step-tick divider
Add a small left gap before the wordmark and a step-tick underline
(_▁ repeated) matched to the logo width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:20:25 +01:00
librelad
e7e9a8ce5c fix(branding): portal glyph stands on feet + wider spacing
The portal between Libre/Portal was a closed ring ("just a circle"); give it
two feet (╨─╨) and a touch more breathing room on each side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:13:41 +01:00
librelad
e2d9e701b9 feat(branding): replace EasyDocker ASCII logo with LibrePortal wordmark
The startup banner (displayLibrePortalLogo in init.sh/start.sh and the
generate_arrays.sh splash) still rendered the old "EASY DOCKER" figlet art.
Swap it for a LibrePortal wordmark — Calvin S mixed-case "Libre"/"Portal"
with a small framed portal glyph between the two words.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:07:55 +01:00
librelad
d5fe1bc56b 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>
2026-05-21 23:33:43 +01:00
18 changed files with 918 additions and 26 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/dashboard.css">
<link rel="stylesheet" href="/css/tasks.css">
<link rel="stylesheet" href="/css/update-notifier.css">
<script>
// Inline data-theme bootstrap — runs before any rendering so the right
// palette tokens resolve on first paint. Synchronously injects a

View File

@ -380,8 +380,8 @@ class PortManager {
<input type="text" class="port-url-path" placeholder="e.g. /,/results/stats.php" value="${port.url_path || ''}" data-index="${index}">
</div>
<div class="port-field port-field-advanced">
<label>Subdomain <span class="help-icon" title="Override the Traefik subdomain for this port (e.g. 'streaming.app' → streaming.app.example.com). Leave empty to inherit the app's CFG_HOST_NAME. Parsed by the framework; label-generation refactor pending.">?</span></label>
<input type="text" class="port-subdomain" placeholder="(inherit hostname)" value="${port.subdomain || ''}" data-index="${index}">
<label>Subdomain <span class="help-icon" title="The subdomain this port is served on — e.g. 'vault' → vault.example.com, or 'admin.app' for a multi-level host. Use @ for the root of your domain. Leave empty to default to the app's name.">?</span></label>
<input type="text" class="port-subdomain" placeholder="vault · @ = root · blank = app name" value="${port.subdomain || ''}" data-index="${index}">
</div>
</div>
</div>

View File

@ -875,6 +875,10 @@ class TasksManager {
if (/^libreportal setup finalize\b/.test(task.command)) return 'LibrePortal - Finalize Setup';
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.
const backupDeleteAllMatch = task.command.match(/libreportal backup app delete_all (\w+)/);
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. */
isLibrePortalSystemTask(task) {
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:

View File

@ -70,6 +70,8 @@ class TopbarComponent {
this.setupLogout();
this.setupConfigUpdateLockout();
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.

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: [],
scripts: [
'/js/components/topbar.js',
'/js/components/update-notifier.js',
'/js/components/mobile-menu.js'
]
});

View File

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

View File

@ -31,9 +31,9 @@ isQuestion() { echo -e -n "${BLUE}QUESTION:${NC} $1 "; }
displayLibrePortalLogo() {
echo "
____ ____ ____ _ _ ___ ____ ____ _ _ ____ ____
|___ |__| [__ \_/ | \ | | | |_/ |___ |__/
|___ | | ___] | |__/ |__| |___ | \_ |___ | \ "
╦ ┬┌┐ ┬─┐┌─┐ ╭─╮ ╔═╗┌─┐┬─┐┌┬┐┌─┐┬
║ │├┴┐├┬┘├┤ │◉│ ╠═╝│ │├┬┘ │ ├─┤│
╩═╝┴└─┘┴└─└─┘ ╨─╨ ╩ └─┘┴└─ ┴ ┴ ┴┴─┘"
echo ""
}

View File

@ -6,11 +6,25 @@
cliHandleUpdateCommands()
{
local update_type="$initial_command2"
if [[ -z "$update_type" ]]; then
checkUpdates;
else
isNotice "Invalid update command: ${RED}$update_type${NC}"
cliShowUpdateHelp;
fi
case "$update_type" in
"")
# 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}"
cliShowUpdateHelp
;;
esac
}

View File

@ -8,9 +8,12 @@ cliShowUpdateHelp()
echo ""
echo "Available Update Commands:"
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 "This command will check for and install the latest LibrePortal updates."
echo "It will backup your current configuration before updating."
echo "These commands check for and install the latest LibrePortal updates."
echo "Your current configuration is backed up before updating."
echo "'check' and 'apply' are also what the WebUI Update notification uses."
echo ""
}

View File

@ -9,6 +9,22 @@ dockerInstallApp()
local app_name_ucfirst="$(tr '[:lower:]' '[:upper:]' <<< ${app_name:0:1})${app_name:1}"
local installFuncName="install${app_name_ucfirst}"
# App slugs are lowercase (e.g. "libreportal"), but installer functions keep
# their own internal capitalisation (e.g. installLibrePortal) — capitalising
# only the first letter can't reproduce that camelCase. If the naive name
# isn't a defined function, resolve it case-insensitively against the real
# function table so any compound brand/app name (LibrePortal, …) just works.
if ! declare -F "$installFuncName" >/dev/null 2>&1; then
local _resolved
_resolved="$(compgen -A function | grep -ixF "install${app_name}" | head -n1)"
[[ -n "$_resolved" ]] && installFuncName="$_resolved"
fi
if ! declare -F "$installFuncName" >/dev/null 2>&1; then
isError "No installer function found for app '${app_name}' (looked for ${installFuncName})."
return 1
fi
if [[ "$reset_network" == "true" ]]; then
export LIBREPORTAL_RESET_NETWORK=1
if declare -f ipRemoveFromDatabase >/dev/null 2>&1; then

View File

@ -39,9 +39,9 @@ isHeader() {
}
echo "
____ ____ ____ _ _ ___ ____ ____ _ _ ____ ____
|___ |__| [__ \_/ | \ | | | |_/ |___ |__/
|___ | | ___] | |__/ |__| |___ | \_ |___ | \ "
╦ ┬┌┐ ┬─┐┌─┐ ╭─╮ ╔═╗┌─┐┬─┐┌┬┐┌─┐┬
║ │├┴┐├┬┘├┤ │◉│ ╠═╝│ │├┬┘ │ ├─┤│
╩═╝┴└─┘┴└─└─┘ ╨─╨ ╩ └─┘┴└─ ┴ ┴ ┴┴─┘"
echo ""
isHeader "Regenerating Array Files"

View File

@ -1,6 +1,16 @@
#!/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"
update_done=false
@ -16,9 +26,9 @@ gitFolderResetAndBackup()
checkSuccess "Copy the configs to the backup folder"
local result=$(copyFolder "$logs_dir" "$backup_install_dir/$backupFolder" "$sudo_user_name")
checkSuccess "Copy the logs to the backup folder"
gitReset;
local result=$(copyFolders "$backup_install_dir/$backupFolder/" "$docker_dir" "$sudo_user_name")
checkSuccess "Copy the backed up folders back into the installation directory"
@ -28,6 +38,11 @@ gitFolderResetAndBackup()
gitCleanInstallBackups;
gitUntrackFiles;
}
gitFolderResetAndBackup()
{
gitPerformUpdate;
isSuccessful "Custom changes have been discarded successfully"
echo ""

View File

@ -1,5 +1,86 @@
#!/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()
{
local param1="$1"

View File

@ -7,5 +7,168 @@ webuiSystemUpdate() {
webuiSystemInfo
webuiSystemDisk
webuiSystemMemory
webuiSystemUpdateCheck
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"
}

View File

@ -13,9 +13,9 @@ displayLibrePortalLogo()
{
[[ "$LIBREPORTAL_SKIP_LOGO" == "1" ]] && return
echo "
____ ____ ____ _ _ ___ ____ ____ _ _ ____ ____
|___ |__| [__ \_/ | \ | | | |_/ |___ |__/
|___ | | ___] | |__/ |__| |___ | \_ |___ | \ "
╦ ┬┌┐ ┬─┐┌─┐ ╭─╮ ╔═╗┌─┐┬─┐┌┬┐┌─┐┬
║ │├┴┐├┬┘├┤ │◉│ ╠═╝│ │├┬┘ │ ├─┤│
╩═╝┴└─┘┴└─└─┘ ╨─╨ ╩ └─┘┴└─ ┴ ┴ ┴┴─┘"
echo ""
}