diff --git a/containers/libreportal/frontend/core/boot/js/system-loader.js b/containers/libreportal/frontend/core/boot/js/system-loader.js index 2d13dfa..3ffe0b1 100755 --- a/containers/libreportal/frontend/core/boot/js/system-loader.js +++ b/containers/libreportal/frontend/core/boot/js/system-loader.js @@ -68,6 +68,7 @@ class SystemLoader { scripts: [ '/core/topbar/js/topbar.js', '/core/update-notifier/js/update-notifier.js', + '/core/network-notifier/js/network-notifier.js', '/core/topbar/js/mobile-menu.js' ] }); diff --git a/containers/libreportal/frontend/core/network-notifier/css/network-notifier.css b/containers/libreportal/frontend/core/network-notifier/css/network-notifier.css new file mode 100644 index 0000000..069d853 --- /dev/null +++ b/containers/libreportal/frontend/core/network-notifier/css/network-notifier.css @@ -0,0 +1,243 @@ +/* Network Notifier — topbar badge, dashboard banner, and details panel. + Driven by js/network-notifier.js. Uses the --page-network identity hue + (rose) so it reads as a distinct "network attention" signal next to the + amber update badge. Falls back gracefully if the token is absent. */ + +/* ---- Topbar badge -------------------------------------------------------- */ + +.network-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border: 1px solid var(--page-network, #e5556e); + border-radius: 6px; + background: rgba(var(--page-network-rgb, 229, 85, 110), 0.14); + color: var(--page-network, #e5556e); + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.2s, transform 0.1s; + white-space: nowrap; +} + +.network-badge:hover { background: rgba(var(--page-network-rgb, 229, 85, 110), 0.24); } +.network-badge:active { transform: scale(0.97); } + +.network-badge-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--page-network, #e5556e); + box-shadow: 0 0 0 0 rgba(var(--page-network-rgb, 229, 85, 110), 0.6); + animation: network-badge-pulse 2s infinite; +} + +@keyframes network-badge-pulse { + 0% { box-shadow: 0 0 0 0 rgba(var(--page-network-rgb, 229, 85, 110), 0.6); } + 70% { box-shadow: 0 0 0 7px rgba(var(--page-network-rgb, 229, 85, 110), 0); } + 100% { box-shadow: 0 0 0 0 rgba(var(--page-network-rgb, 229, 85, 110), 0); } +} + +@media (prefers-reduced-motion: reduce) { + .network-badge-dot { animation: none; } +} + +/* ---- Dashboard banner ---------------------------------------------------- */ + +.network-banner { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + padding: 16px 20px; + border: 1px solid var(--page-network, #e5556e); + border-left-width: 4px; + border-radius: 10px; + background: rgba(var(--page-network-rgb, 229, 85, 110), 0.1); + color: var(--text-primary, #fff); +} + +.network-banner-icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + color: var(--page-network, #e5556e); +} + +.network-banner-text { flex: 1 1 auto; min-width: 0; } +.network-banner-title { font-weight: 700; font-size: 1rem; } + +.network-banner-sub { + margin-top: 2px; + font-size: 0.85rem; + color: var(--text-muted, #9aa); +} + +.network-banner-actions { + display: flex; + gap: 8px; + flex: 0 0 auto; +} + +/* ---- Shared action buttons ----------------------------------------------- */ + +.network-btn-primary, +.network-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; +} + +.network-btn-primary { + background: var(--page-network, #e5556e); + color: var(--text-on-accent, #fff); +} + +.network-btn-primary:hover { filter: brightness(1.08); } + +.network-btn-secondary { + background: transparent; + border-color: var(--border-color, rgba(255, 255, 255, 0.2)); + color: var(--text-primary, #fff); +} + +.network-btn-secondary:hover { background: var(--surface-hover, rgba(255, 255, 255, 0.08)); } + +/* ---- Details panel (modal) ----------------------------------------------- */ + +.network-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); +} + +.network-panel { + width: 100%; + max-width: 460px; + 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; +} + +.network-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)); +} + +.network-panel-header h3 { margin: 0; font-size: 1.05rem; } + +.network-panel-close { + border: none; + background: transparent; + color: var(--text-muted, #9aa); + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.network-panel-close:hover { color: var(--text-primary, #fff); } + +.network-panel-status { + padding: 14px 20px 0; + font-size: 0.9rem; + color: var(--text-secondary, var(--text-muted, #9aa)); +} + +.network-panel-status.is-conflict { + color: var(--page-network, #e5556e); + font-weight: 600; +} + +.network-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); +} + +.network-panel-rows { + margin: 14px 0 0; + padding: 0 20px; +} + +.network-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; +} + +.network-panel-row:last-child { border-bottom: none; } +.network-panel-row dt { color: var(--text-muted, #9aa); margin: 0; } + +.network-panel-row dd { + margin: 0; + font-weight: 600; + text-align: right; + word-break: break-word; +} + +/* affected-apps list */ +.network-panel-apps { + margin: 14px 20px 0; + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + border-radius: 8px; + overflow: hidden; +} + +.network-panel-app { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + font-size: 0.84rem; + border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06)); +} + +.network-panel-app:last-child { border-bottom: none; } +.network-panel-app-name { font-weight: 600; } +.network-panel-app-ip { color: var(--text-muted, #9aa); font-family: var(--font-mono, monospace); } + +.network-panel-note { + margin: 14px 20px 0; + font-size: 0.8rem; + color: var(--text-muted, #9aa); + line-height: 1.45; +} + +.network-panel-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 18px 20px 20px; +} + +@media (max-width: 600px) { + .network-banner { flex-wrap: wrap; } + .network-banner-actions { width: 100%; } + .network-banner-actions button { flex: 1 1 auto; } +} diff --git a/containers/libreportal/frontend/core/network-notifier/js/network-notifier.js b/containers/libreportal/frontend/core/network-notifier/js/network-notifier.js new file mode 100644 index 0000000..0320c5e --- /dev/null +++ b/containers/libreportal/frontend/core/network-notifier/js/network-notifier.js @@ -0,0 +1,331 @@ +// Network Notifier +// ----------------------------------------------------------------------------- +// Surfaces docker-network drift across the WebUI — apps whose allocated static +// 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 +// when there's a real conflict to act on: +// * a badge in the global topbar (after the update badge), and +// * a banner on the dashboard. +// Both are driven by /data/system/network_status.json, written host-side by +// webuiSystemNetworkCheck (scripts/webui/data/generators/system/). +// +// Actions go through the normal task pipeline so the user can watch them stream +// on the Tasks page: +// * "Heal now" -> task `libreportal system network heal` (re-IPs stranded +// apps from the corrected subnet; published ports preserved) +// * "Re-check" -> task `libreportal system network check` +// +// This file owns no detection logic — it only reads the status file. + +class NetworkNotifier { + constructor() { + this.status = null; + this.fetching = null; + 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 s = await fetch('/data/system/network_status.json', { cache: 'no-store' }) + .then(r => r.ok ? r.json() : null).catch(() => null); + if (s !== null) this.status = s; // keep last-good on a failed fetch + 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(); + + // Topbar HTML and this script load independently; retry until + // .topbar-controls exists so the badge appears regardless of 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 a network heal/check task finishes so the + // badge clears without waiting for the next poll. + window.taskRefresh?.register({ + id: 'network-badge', + match: (d) => d.action === 'system_network_heal' + || /^libreportal system network\b/.test((d.task && d.task.command) || d.command || ''), + run: () => this.refresh(), + debounceMs: 1500, + }); + } + + // Called by TopbarComponent.init() once the topbar DOM exists. + onTopbarReady() { + this.renderTopbarBadge(); + this.refresh(); + } + + _hasConflicts() { return !!(this.status && this.status.conflicts_found === true); } + + // ---- topbar badge -------------------------------------------------------- + + renderTopbarBadge() { + const controls = document.querySelector('.topbar-controls'); + if (!controls) return; + + let badge = document.getElementById('network-badge'); + + if (!this._hasConflicts()) { + if (badge) badge.remove(); + return; + } + + if (!badge) { + badge = document.createElement('button'); + badge.id = 'network-badge'; + badge.className = 'network-badge'; + badge.type = 'button'; + badge.addEventListener('click', () => this.openPanel()); + // Sit just after the update badge (if any) so the two coexist in a stable + // order — update first, network second — rather than racing for firstChild. + const anchor = document.getElementById('update-badge'); + if (anchor && anchor.parentNode === controls) controls.insertBefore(badge, anchor.nextSibling); + else controls.insertBefore(badge, controls.firstChild); + } + + const n = this.status.conflict_count || 0; + badge.title = `Network attention needed${n ? ` — ${n} item${n === 1 ? '' : 's'}` : ''}`; + badge.setAttribute('aria-label', badge.title); + badge.innerHTML = ` + + + Network`; + } + + // ---- dashboard banner ---------------------------------------------------- + + renderDashboardBanner() { + const main = document.querySelector('.dashboard-main'); + if (!main) return; // not on the dashboard + + let banner = document.getElementById('network-banner'); + + // Attention-only: render nothing unless there's a real conflict. + if (!this._hasConflicts()) { + if (banner) banner.remove(); + return; + } + + if (!banner) { + banner = document.createElement('div'); + banner.id = 'network-banner'; + // After the update banner if present; else top of the dashboard. + const anchor = document.getElementById('update-banner'); + if (anchor && anchor.parentNode === main) main.insertBefore(banner, anchor.nextSibling); + else main.insertBefore(banner, main.firstChild); + } + + const s = this.status; + const n = s.conflict_count || 0; + const net = s.network_name || 'docker'; + const sub = s.docker_subnet + ? `${n} item${n === 1 ? '' : 's'} not on the ${this._escape(net)} network (${this._escape(s.docker_subnet)})` + : (s.error ? this._escape(s.error) : `${n} network item${n === 1 ? '' : 's'} need attention`); + + const netIcon = ` + `; + + banner.className = 'network-banner'; + banner.innerHTML = ` +
+ + `; + + const details = banner.querySelector('#network-banner-details'); + if (details) details.addEventListener('click', () => this.openPanel()); + const healBtn = banner.querySelector('#network-banner-heal'); + if (healBtn) healBtn.addEventListener('click', () => this.runHeal()); + } + + // ---- details panel (self-contained modal) -------------------------------- + + openPanel() { + this.closePanel(); + const s = this.status || {}; + const apps = Array.isArray(s.apps) ? s.apps : []; + + const overlay = document.createElement('div'); + overlay.id = 'network-panel-overlay'; + overlay.className = 'network-panel-overlay'; + overlay.addEventListener('click', (e) => { if (e.target === overlay) this.closePanel(); }); + + const rows = [ + ['Network', s.network_name || '—'], + ['Docker subnet', s.docker_subnet || '—'], + ['Config subnet', s.config_subnet || '—'], + ['Conflicts', (s.conflict_count ?? 0).toString()], + ['Last checked', this._formatTime(s.checked_at)], + ]; + + const appList = apps.length + ? `Healing re-assigns each stranded app a fresh IP from the current subnet and restarts it. Published host ports are preserved. Progress streams on the Tasks page.
' : ''} +