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:
parent
75c2e6d9a5
commit
d5fe1bc56b
237
containers/libreportal/frontend/css/update-notifier.css
Normal file
237
containers/libreportal/frontend/css/update-notifier.css
Normal 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; }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
351
containers/libreportal/frontend/js/components/update-notifier.js
Normal file
351
containers/libreportal/frontend/js/components/update-notifier.js
Normal 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 ? ` · ${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">×</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) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateNotifier = window.updateNotifier || new UpdateNotifier();
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => window.updateNotifier.start());
|
||||||
|
} else {
|
||||||
|
window.updateNotifier.start();
|
||||||
|
}
|
||||||
@ -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'
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
||||||
isNotice "Invalid update command: ${RED}$update_type${NC}"
|
checkUpdates
|
||||||
cliShowUpdateHelp;
|
;;
|
||||||
fi
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user