Compare commits

..

No commits in common. "11e79e6d81efd03f36437b11488ff4adc88098b0" and "a16c93721e85e890123397ec564266637bf8f95d" have entirely different histories.

6 changed files with 0 additions and 579 deletions

View File

@ -68,7 +68,6 @@ 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'
]
});

View File

@ -1,243 +0,0 @@
/* 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; }
}

View File

@ -1,331 +0,0 @@
// 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 = `
<span class="network-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">
<rect x="2" y="2" width="8" height="8" rx="1"></rect>
<rect x="14" y="14" width="8" height="8" rx="1"></rect>
<path d="M6 10v4a2 2 0 0 0 2 2h6"></path>
</svg>
<span class="network-badge-text">Network</span>`;
}
// ---- 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 = `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="8" height="8" rx="1"></rect>
<rect x="14" y="14" width="8" height="8" rx="1"></rect>
<path d="M6 10v4a2 2 0 0 0 2 2h6"></path>
</svg>`;
banner.className = 'network-banner';
banner.innerHTML = `
<div class="network-banner-icon" aria-hidden="true">${netIcon}</div>
<div class="network-banner-text">
<div class="network-banner-title">Network attention needed</div>
<div class="network-banner-sub">${sub}</div>
</div>
<div class="network-banner-actions">
<button type="button" class="network-btn-secondary" id="network-banner-details">Details</button>
${s.can_auto_heal ? '<button type="button" class="network-btn-primary" id="network-banner-heal">Heal now</button>' : ''}
</div>`;
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
? `<div class="network-panel-apps">
${apps.map(a => `
<div class="network-panel-app">
<span class="network-panel-app-name">${this._escape(a.app)}${a.service && a.service !== a.app ? ` / ${this._escape(a.service)}` : ''}</span>
<span class="network-panel-app-ip">${this._escape(a.stored_ip || '')}</span>
</div>`).join('')}
</div>`
: '';
const statusLine = this._hasConflicts()
? 'Some apps have a static IP outside the docker network and cant be reached until re-IPd.'
: 'No network conflicts detected.';
overlay.innerHTML = `
<div class="network-panel" role="dialog" aria-modal="true" aria-label="Network status">
<div class="network-panel-header">
<h3>Network Status</h3>
<button type="button" class="network-panel-close" id="network-panel-close" aria-label="Close">&times;</button>
</div>
<div class="network-panel-status ${this._hasConflicts() ? 'is-conflict' : ''}">${this._escape(statusLine)}</div>
${s.error ? `<div class="network-panel-error">${this._escape(s.error)}</div>` : ''}
<dl class="network-panel-rows">
${rows.map(([k, v]) => `<div class="network-panel-row"><dt>${this._escape(k)}</dt><dd>${this._escape(v)}</dd></div>`).join('')}
</dl>
${appList}
${s.can_auto_heal && this._hasConflicts() ? '<p class="network-panel-note">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.</p>' : ''}
<div class="network-panel-actions">
<button type="button" class="network-btn-secondary" id="network-panel-check">Re-check</button>
${s.can_auto_heal && this._hasConflicts() ? '<button type="button" class="network-btn-primary" id="network-panel-heal">Heal now</button>' : ''}
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#network-panel-close').addEventListener('click', () => this.closePanel());
overlay.querySelector('#network-panel-check').addEventListener('click', () => this.checkNow());
const heal = overlay.querySelector('#network-panel-heal');
if (heal) heal.addEventListener('click', () => this.runHeal());
this._escHandler = (e) => { if (e.key === 'Escape') this.closePanel(); };
document.addEventListener('keydown', this._escHandler);
}
closePanel() {
const overlay = document.getElementById('network-panel-overlay');
if (overlay) overlay.remove();
if (this._escHandler) {
document.removeEventListener('keydown', this._escHandler);
this._escHandler = null;
}
}
// ---- actions -------------------------------------------------------------
async runHeal() {
this.closePanel();
try {
await this._createTask('libreportal system network heal');
this._toast('Network heal started — follow progress on the Tasks page.', 'info');
this._goToTasks();
} catch (e) {
this._toast('Could not start the network heal: ' + e.message, 'error');
}
}
async checkNow() {
try {
await this._createTask('libreportal system network check');
this._toast('Re-checking the network…', 'info');
setTimeout(() => this.refresh(), 4000);
} catch (e) {
this._toast('Could not re-check the network: ' + e.message, 'error');
}
}
// ---- helpers -------------------------------------------------------------
async _createTask(command) {
if (window.tasksManager?.taskManager?.createTask) {
return window.tasksManager.taskManager.createTask(command, 'system_network_heal', null, '');
}
if (typeof TaskManager !== 'undefined') {
return new TaskManager().createTask(command, 'system_network_heal', null, '');
}
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, type: 'system_network_heal', 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(`[network] ${message}`);
}
_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.networkNotifier = window.networkNotifier || new NetworkNotifier();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => window.networkNotifier.start());
} else {
window.networkNotifier.start();
}

View File

@ -25,5 +25,4 @@
--page-ssh: #9b7bf0; --page-ssh-rgb: 155, 123, 240;
--page-system: #f0883e; --page-system-rgb: 240, 136, 62;
--page-updater: #2bb6c4; --page-updater-rgb: 43, 182, 196; /* App Updater — teal */
--page-network: #e5556e; --page-network-rgb: 229, 85, 110; /* Network drift — rose */
}

View File

@ -81,8 +81,6 @@ class TopbarComponent {
this.autoEnableDevModeIfNeeded();
// Mount the "out of date" badge now that .topbar-controls exists.
window.updateNotifier?.onTopbarReady();
// …and the network-drift badge (sits just after the update badge).
window.networkNotifier?.onTopbarReady();
}
// 10-click LibrePortal-logo easter egg → toggles CFG_DEV_MODE. Inspired by

View File

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