Merge claude/1

This commit is contained in:
librelad 2026-06-12 19:47:41 +01:00
commit 86f84a62d3
4 changed files with 119 additions and 196 deletions

View File

@ -207,8 +207,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. // Re-assert the network-attention banner now the dashboard DOM is in place
window.updateNotifier?.renderDashboardBanner(); // (it leads the dashboard only when there's a real conflict to act on).
window.networkNotifier?.renderDashboardBanner();
} }

View File

@ -4,7 +4,7 @@
// IP no longer falls inside the shared network's real subnet (the "network // IP no longer falls inside the shared network's real subnet (the "network
// recreated with a different /24, app stranded" case). Two surfaces, shown ONLY // recreated with a different /24, app stranded" case). Two surfaces, shown ONLY
// when there's a real conflict to act on: // when there's a real conflict to act on:
// * a badge in the global topbar (after the update badge), and // * a badge in the global topbar (after the update pill), and
// * a banner on the dashboard. // * a banner on the dashboard.
// Both are driven by /data/system/network_status.json, written host-side by // Both are driven by /data/system/network_status.json, written host-side by
// webuiSystemNetworkCheck (scripts/webui/data/generators/system/). // webuiSystemNetworkCheck (scripts/webui/data/generators/system/).
@ -108,9 +108,9 @@ class NetworkNotifier {
badge.className = 'network-badge'; badge.className = 'network-badge';
badge.type = 'button'; badge.type = 'button';
badge.addEventListener('click', () => this.openPanel()); badge.addEventListener('click', () => this.openPanel());
// Sit just after the update badge (if any) so the two coexist in a stable // Sit just after the update pill so the two coexist in a stable order —
// order — update first, network second — rather than racing for firstChild. // update first, network second — rather than racing for firstChild.
const anchor = document.getElementById('update-badge'); const anchor = document.getElementById('update-pill');
if (anchor && anchor.parentNode === controls) controls.insertBefore(badge, anchor.nextSibling); if (anchor && anchor.parentNode === controls) controls.insertBefore(badge, anchor.nextSibling);
else controls.insertBefore(badge, controls.firstChild); else controls.insertBefore(badge, controls.firstChild);
} }
@ -145,10 +145,8 @@ class NetworkNotifier {
if (!banner) { if (!banner) {
banner = document.createElement('div'); banner = document.createElement('div');
banner.id = 'network-banner'; banner.id = 'network-banner';
// After the update banner if present; else top of the dashboard. // Attention-only, so it leads the dashboard when a conflict is present.
const anchor = document.getElementById('update-banner'); main.insertBefore(banner, main.firstChild);
if (anchor && anchor.parentNode === main) main.insertBefore(banner, anchor.nextSibling);
else main.insertBefore(banner, main.firstChild);
} }
const s = this.status; const s = this.status;

View File

@ -2,97 +2,78 @@
Driven by js/update-notifier.js. Colours come from the active Driven by js/update-notifier.js. Colours come from the active
theme tokens (with safe fallbacks) so it tracks every palette. */ theme tokens (with safe fallbacks) so it tracks every palette. */
/* ---- Topbar badge -------------------------------------------------------- */ /* ---- Topbar pill (persistent update status) ------------------------------ */
.update-badge { .update-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 7px 12px; padding: 6px 11px;
border: 1px solid var(--status-warning, #e0a106); border: 1px solid transparent;
border-radius: 6px; border-radius: 8px;
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.14);
color: var(--status-warning, #e0a106);
font-weight: 600; font-weight: 600;
font-size: 0.85rem; font-size: 0.82rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s, transform 0.1s;
white-space: nowrap; white-space: nowrap;
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.1s;
} }
.update-badge:hover { .update-pill svg { flex: 0 0 auto; }
.update-pill:active { transform: scale(0.97); }
/* Calm states up to date, local install, mid-check. Low emphasis: the
version/status sits quietly alongside the other topbar controls. */
.update-pill.is-ok,
.update-pill.is-local,
.update-pill.is-checking {
color: var(--text-secondary, #b8c0cc);
background: var(--surface-bg, rgba(255, 255, 255, 0.04));
border-color: var(--border-color, rgba(255, 255, 255, 0.1));
}
.update-pill.is-ok:hover,
.update-pill.is-local:hover,
.update-pill.is-checking:hover {
background: var(--surface-hover, rgba(255, 255, 255, 0.08));
color: var(--text-primary, #fff);
}
.update-pill.is-ok svg { color: var(--status-success, #2ea043); }
.update-pill.is-local svg,
.update-pill.is-checking svg { color: var(--text-muted, #8a93a3); }
.update-pill.is-checking svg { animation: update-pill-spin 0.9s linear infinite; }
/* Update available — the one loud state: accent border, tint, pulsing dot. */
.update-pill.is-update {
color: var(--status-warning, #e0a106);
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.14);
border-color: var(--status-warning, #e0a106);
}
.update-pill.is-update:hover {
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.24); background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.24);
} }
.update-badge:active { transform: scale(0.97); } .update-pill-dot {
width: 7px;
.update-badge-dot { height: 7px;
width: 8px;
height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--status-warning, #e0a106); background: var(--status-warning, #e0a106);
box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6); box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6);
animation: update-badge-pulse 2s infinite; animation: update-pill-pulse 2s infinite;
} }
@keyframes update-badge-pulse { @keyframes update-pill-pulse {
0% { box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6); } 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); } 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); } 100% { box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0); }
} }
@keyframes update-pill-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.update-badge-dot { animation: none; } .update-pill-dot { animation: none; }
} .update-pill.is-checking svg { 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 ----------------------------------------------- */ /* ---- Shared action buttons ----------------------------------------------- */
@ -229,9 +210,3 @@
gap: 8px; gap: 8px;
padding: 18px 20px 20px; 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

@ -1,13 +1,16 @@
// Update Notifier // Update Notifier
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Surfaces LibrePortal's "out of date" state across the WebUI: // Surfaces LibrePortal's version + "out of date" state in one persistent pill
// * a persistent badge in the global topbar (visible on every page), and // in the global topbar (visible on every page). The pill stays calm by default —
// * a banner on the dashboard, // the installed version, or "Up to date" — and only escalates to an accented,
// both driven by /data/system/update_status.json (written host-side by // pulsing "Update" when one is actually waiting. Clicking it opens a details
// modal carrying the full version readout and the update / check actions.
//
// Driven by /data/system/update_status.json (written host-side by
// webuiSystemUpdateCheck — see scripts/webui/data/generators/system/). // webuiSystemUpdateCheck — see scripts/webui/data/generators/system/).
// //
// The two actions both go through the normal task pipeline so the user can // Both actions go through the normal task pipeline so the user can watch them
// watch them stream on the Tasks page, exactly like an app install: // stream on the Tasks page, exactly like an app install:
// * "Update now" -> task `libreportal update apply` // * "Update now" -> task `libreportal update apply`
// * "Check for updates" -> task `libreportal update check` // * "Check for updates" -> task `libreportal update check`
// //
@ -21,6 +24,7 @@ class UpdateNotifier {
this.pollMs = 5 * 60 * 1000; // re-read the status file every 5 min this.pollMs = 5 * 60 * 1000; // re-read the status file every 5 min
this.pollTimer = null; this.pollTimer = null;
this.started = false; this.started = false;
this._checking = false; // a manual "check for updates" is in flight
} }
// ---- data ---------------------------------------------------------------- // ---- data ----------------------------------------------------------------
@ -47,8 +51,7 @@ class UpdateNotifier {
async refresh() { async refresh() {
await this.fetchStatus(); await this.fetchStatus();
this.renderTopbarBadge(); this.renderTopbarPill();
this.renderDashboardBanner();
} }
// ---- lifecycle ----------------------------------------------------------- // ---- lifecycle -----------------------------------------------------------
@ -65,7 +68,7 @@ class UpdateNotifier {
// first load regardless of which won the race. // first load regardless of which won the race.
let tries = 0; let tries = 0;
const ensure = setInterval(() => { const ensure = setInterval(() => {
if (document.querySelector('.topbar-controls')) { this.renderTopbarBadge(); clearInterval(ensure); } if (document.querySelector('.topbar-controls')) { this.renderTopbarPill(); clearInterval(ensure); }
else if (++tries > 30) clearInterval(ensure); // ~15s ceiling else if (++tries > 30) clearInterval(ensure); // ~15s ceiling
}, 500); }, 500);
@ -77,7 +80,7 @@ class UpdateNotifier {
// with the task-refresh coordinator (single source of truth); the debounce // with the task-refresh coordinator (single source of truth); the debounce
// gives the host a beat to finish writing update_status.json. // gives the host a beat to finish writing update_status.json.
window.taskRefresh?.register({ window.taskRefresh?.register({
id: 'update-badge', id: 'update-pill',
match: (d) => d.action === 'update' || d.action === 'system_update' match: (d) => d.action === 'update' || d.action === 'system_update'
|| /^libreportal update\b/.test((d.task && d.task.command) || d.command || ''), || /^libreportal update\b/.test((d.task && d.task.command) || d.command || ''),
run: () => this.refresh(), run: () => this.refresh(),
@ -87,121 +90,62 @@ class UpdateNotifier {
// Called by TopbarComponent.init() once the topbar DOM exists. // Called by TopbarComponent.init() once the topbar DOM exists.
onTopbarReady() { onTopbarReady() {
this.renderTopbarBadge(); this.renderTopbarPill();
this.refresh(); this.refresh();
} }
// ---- topbar badge -------------------------------------------------------- // ---- topbar pill ---------------------------------------------------------
// One persistent status control in the global topbar, on every page. Calm by
renderTopbarBadge() { // default (the installed version, or "Up to date"); escalates to an accented,
// pulsing "Update" only when one is actually waiting, and shows a spinner
// while a manual check runs. Clicking it opens the details modal.
renderTopbarPill() {
const controls = document.querySelector('.topbar-controls'); const controls = document.querySelector('.topbar-controls');
if (!controls) return; 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; const s = this.status;
let pill = document.getElementById('update-pill');
// No status file yet (fresh install before the first check) — show nothing. // Nothing known yet (fresh install before the first check) — show nothing.
if (!s) { if (!s) { if (pill) pill.remove(); return; }
if (banner) banner.remove();
return; if (!pill) {
pill = document.createElement('button');
pill.id = 'update-pill';
pill.type = 'button';
pill.addEventListener('click', () => this.openPanel());
controls.insertBefore(pill, controls.firstChild);
} }
if (!banner) { const refreshIcon = `<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>`;
banner = document.createElement('div'); const checkIcon = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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>`;
banner.id = 'update-banner'; const boxIcon = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`;
main.insertBefore(banner, main.firstChild);
}
const refreshIcon = ` const ver = (s.current_version && s.current_version !== 'unknown') ? `v${s.current_version}` : '';
<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 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'; let state, icon, text, title, dot = '';
banner.className = 'update-banner update-banner-ok'; if (this._checking) {
banner.innerHTML = ` state = 'is-checking'; icon = refreshIcon; text = 'Checking…';
<div class="update-banner-icon" aria-hidden="true">${checkIcon}</div> title = 'Checking for updates…';
<div class="update-banner-text"> } else if (s.update_available) {
<div class="update-banner-title">${this._escape(verText)}</div> state = 'is-update'; icon = refreshIcon; text = 'Update';
<div class="update-banner-sub">${this._escape(subText)}</div> dot = '<span class="update-pill-dot" aria-hidden="true"></span>';
</div> const to = this._versionLabel();
<div class="update-banner-actions"> title = `Update available${to ? ' — ' + to : ''}`;
<button type="button" class="update-btn-secondary" id="update-banner-details">Details</button> } else if (local) {
${local ? '' : '<button type="button" class="update-btn-secondary" id="update-banner-check">Check for updates</button>'} // Local/dev install: no remote to check against — show the version plainly.
</div>`; state = 'is-local'; icon = boxIcon; text = ver || 'Local';
title = `Local installation — updates are managed manually${ver ? ' · ' + ver : ''}`;
} else {
state = 'is-ok'; icon = checkIcon; text = 'Up to date';
title = `LibrePortal${ver ? ' ' + ver : ''} · up to date`;
} }
const details = banner.querySelector('#update-banner-details'); pill.className = `update-pill ${state}`;
if (details) details.addEventListener('click', () => this.openPanel()); pill.title = title;
const updateBtn = banner.querySelector('#update-banner-update'); pill.setAttribute('aria-label', title);
if (updateBtn) updateBtn.addEventListener('click', () => this.runUpdate()); pill.innerHTML = `${dot}${icon}<span class="update-pill-text">${this._escape(text)}</span>`;
const checkBtn = banner.querySelector('#update-banner-check');
if (checkBtn) checkBtn.addEventListener('click', () => this.checkNow());
} }
// ---- details panel (self-contained modal) -------------------------------- // ---- details panel (self-contained modal) --------------------------------
@ -283,12 +227,17 @@ class UpdateNotifier {
} }
async checkNow() { async checkNow() {
this._checking = true;
this.renderTopbarPill();
try { try {
await this._createTask('libreportal update check'); await this._createTask('libreportal update check');
this._toast('Checking for updates…', 'info'); this._toast('Checking for updates…', 'info');
// The check task rewrites update_status.json; refresh shortly after. // The check task rewrites update_status.json; clear the spinner and
setTimeout(() => this.refresh(), 4000); // re-read shortly after so the pill settles on the fresh state.
setTimeout(() => { this._checking = false; this.refresh(); }, 4000);
} catch (e) { } catch (e) {
this._checking = false;
this.renderTopbarPill();
this._toast('Could not check for updates: ' + e.message, 'error'); this._toast('Could not check for updates: ' + e.message, 'error');
} }
} }