Compare commits
2 Commits
a16c93721e
...
11e79e6d81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e79e6d81 | ||
|
|
f1e1330cd8 |
@ -68,6 +68,7 @@ class SystemLoader {
|
|||||||
scripts: [
|
scripts: [
|
||||||
'/core/topbar/js/topbar.js',
|
'/core/topbar/js/topbar.js',
|
||||||
'/core/update-notifier/js/update-notifier.js',
|
'/core/update-notifier/js/update-notifier.js',
|
||||||
|
'/core/network-notifier/js/network-notifier.js',
|
||||||
'/core/topbar/js/mobile-menu.js'
|
'/core/topbar/js/mobile-menu.js'
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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 = `
|
||||||
|
<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 can’t be reached until re-IP’d.'
|
||||||
|
: '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">×</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) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.networkNotifier = window.networkNotifier || new NetworkNotifier();
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => window.networkNotifier.start());
|
||||||
|
} else {
|
||||||
|
window.networkNotifier.start();
|
||||||
|
}
|
||||||
@ -25,4 +25,5 @@
|
|||||||
--page-ssh: #9b7bf0; --page-ssh-rgb: 155, 123, 240;
|
--page-ssh: #9b7bf0; --page-ssh-rgb: 155, 123, 240;
|
||||||
--page-system: #f0883e; --page-system-rgb: 240, 136, 62;
|
--page-system: #f0883e; --page-system-rgb: 240, 136, 62;
|
||||||
--page-updater: #2bb6c4; --page-updater-rgb: 43, 182, 196; /* App Updater — teal */
|
--page-updater: #2bb6c4; --page-updater-rgb: 43, 182, 196; /* App Updater — teal */
|
||||||
|
--page-network: #e5556e; --page-network-rgb: 229, 85, 110; /* Network drift — rose */
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,8 @@ class TopbarComponent {
|
|||||||
this.autoEnableDevModeIfNeeded();
|
this.autoEnableDevModeIfNeeded();
|
||||||
// Mount the "out of date" badge now that .topbar-controls exists.
|
// Mount the "out of date" badge now that .topbar-controls exists.
|
||||||
window.updateNotifier?.onTopbarReady();
|
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
|
// 10-click LibrePortal-logo easter egg → toggles CFG_DEV_MODE. Inspired by
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
<link rel="stylesheet" href="/components/tasks/css/tasks.css">
|
<link rel="stylesheet" href="/components/tasks/css/tasks.css">
|
||||||
<link rel="stylesheet" href="/components/updater/css/updater.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/update-notifier/css/update-notifier.css">
|
||||||
|
<link rel="stylesheet" href="/core/network-notifier/css/network-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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user