Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29267c9933 | |||
| 03375ef22f | |||
| c025da85c1 |
+16
-7
@@ -45,14 +45,14 @@ async function refreshAll() {
|
|||||||
updateUnifiTable(net.unifi || []);
|
updateUnifiTable(net.unifi || []);
|
||||||
updateTopology(net.hosts || {});
|
updateTopology(net.hosts || {});
|
||||||
} else {
|
} else {
|
||||||
console.warn('Network API failed:', netResult.reason);
|
showToast('Network data unavailable', 'warning');
|
||||||
}
|
}
|
||||||
if (statusResult.status === 'fulfilled') {
|
if (statusResult.status === 'fulfilled') {
|
||||||
const status = statusResult.value;
|
const status = statusResult.value;
|
||||||
updateEventsTable(status.events || [], status.total_active);
|
updateEventsTable(status.events || [], status.total_active);
|
||||||
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
|
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
|
||||||
} else {
|
} else {
|
||||||
console.warn('Status API failed:', statusResult.reason);
|
showToast('Status data unavailable', 'warning');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (refreshBtn) refreshBtn.classList.remove('is-loading');
|
if (refreshBtn) refreshBtn.classList.remove('is-loading');
|
||||||
@@ -86,11 +86,13 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
|
|||||||
if (!staleBanner) {
|
if (!staleBanner) {
|
||||||
staleBanner = document.createElement('div');
|
staleBanner = document.createElement('div');
|
||||||
staleBanner.id = 'stale-banner';
|
staleBanner.id = 'stale-banner';
|
||||||
staleBanner.className = 'stale-banner';
|
staleBanner.className = 'lt-alert lt-alert--warning';
|
||||||
|
staleBanner.innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>';
|
||||||
document.querySelector('.lt-main').prepend(staleBanner);
|
document.querySelector('.lt-main').prepend(staleBanner);
|
||||||
}
|
}
|
||||||
const mins = Math.floor(checkAge / 60);
|
const mins = Math.floor(checkAge / 60);
|
||||||
staleBanner.textContent = `⚠ Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
|
staleBanner.querySelector('.lt-alert-msg').textContent =
|
||||||
|
`Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
|
||||||
staleBanner.style.display = '';
|
staleBanner.style.display = '';
|
||||||
} else if (staleBanner) {
|
} else if (staleBanner) {
|
||||||
staleBanner.style.display = 'none';
|
staleBanner.style.display = 'none';
|
||||||
@@ -138,6 +140,11 @@ function updateTopology(hosts) {
|
|||||||
badge.className = `topo-badge topo-badge-${host.status}`;
|
badge.className = `topo-badge topo-badge-${host.status}`;
|
||||||
badge.textContent = host.status;
|
badge.textContent = host.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Animate the 10G drop-wire red+dashed when host is down
|
||||||
|
document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => {
|
||||||
|
wire.classList.toggle('wire-down', host.status === 'down');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +160,8 @@ function updateUnifiTable(devices) {
|
|||||||
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||||
data-sup-type="unifi_device"
|
data-sup-type="unifi_device"
|
||||||
data-sup-name="${lt.escHtml(d.name)}"
|
data-sup-name="${lt.escHtml(d.name)}"
|
||||||
data-sup-detail="">🔕 Suppress</button>`
|
data-sup-detail=""
|
||||||
|
aria-label="Suppress alerts for ${lt.escHtml(d.name)}">🔕 Suppress</button>`
|
||||||
: '';
|
: '';
|
||||||
return `
|
return `
|
||||||
<tr class="${statusClass}">
|
<tr class="${statusClass}">
|
||||||
@@ -171,7 +179,7 @@ function updateEventsTable(events, totalActive) {
|
|||||||
const wrap = document.getElementById('events-table-wrap');
|
const wrap = document.getElementById('events-table-wrap');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
|
|
||||||
const active = events.filter(e => e.severity !== 'info');
|
const active = (events || []).filter(e => e.severity !== 'info');
|
||||||
if (!active.length) {
|
if (!active.length) {
|
||||||
wrap.innerHTML = '<p class="empty-state">No active alerts ✔</p>';
|
wrap.innerHTML = '<p class="empty-state">No active alerts ✔</p>';
|
||||||
return;
|
return;
|
||||||
@@ -207,7 +215,8 @@ function updateEventsTable(events, totalActive) {
|
|||||||
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||||
data-sup-type="${lt.escHtml(supType)}"
|
data-sup-type="${lt.escHtml(supType)}"
|
||||||
data-sup-name="${lt.escHtml(e.target_name)}"
|
data-sup-name="${lt.escHtml(e.target_name)}"
|
||||||
data-sup-detail="${lt.escHtml(e.target_detail||'')}">🔕</button>
|
data-sup-detail="${lt.escHtml(e.target_detail||'')}"
|
||||||
|
aria-label="Suppress alert for ${lt.escHtml(e.target_name)}">🔕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
+14
-16
@@ -132,6 +132,7 @@
|
|||||||
.badge-neutral { color: var(--text-muted); border-color: var(--text-muted); }
|
.badge-neutral { color: var(--text-muted); border-color: var(--text-muted); }
|
||||||
.badge-resolved { color: var(--text-muted); border-color: var(--border-color); text-decoration: line-through; }
|
.badge-resolved { color: var(--text-muted); border-color: var(--border-color); text-decoration: line-through; }
|
||||||
.badge-suppressed { font-size: .9em; padding: 0; border: none; color: var(--text-muted); }
|
.badge-suppressed { font-size: .9em; padding: 0; border: none; color: var(--text-muted); }
|
||||||
|
.badge-purple { color: var(--accent-purple); border-color: var(--accent-purple); }
|
||||||
|
|
||||||
/* ── Table row state colors ───────────────────────────────────────── */
|
/* ── Table row state colors ───────────────────────────────────────── */
|
||||||
.lt-table tr.row-critical td { background: rgba(255,45,85,.04); }
|
.lt-table tr.row-critical td { background: rgba(255,45,85,.04); }
|
||||||
@@ -179,15 +180,7 @@
|
|||||||
.last-check { font-size: .72em; color: var(--text-muted); }
|
.last-check { font-size: .72em; color: var(--text-muted); }
|
||||||
|
|
||||||
/* ── Stale monitoring banner ──────────────────────────────────────── */
|
/* ── Stale monitoring banner ──────────────────────────────────────── */
|
||||||
.stale-banner {
|
/* .stale-banner replaced by lt-alert--warning */
|
||||||
background: var(--amber-dim);
|
|
||||||
border: 1px solid var(--amber);
|
|
||||||
border-left: 4px solid var(--amber);
|
|
||||||
color: var(--amber);
|
|
||||||
padding: 10px 16px;
|
|
||||||
margin: 0 0 14px;
|
|
||||||
font-size: .88em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Error / empty state containers ───────────────────────────────── */
|
/* ── Error / empty state containers ───────────────────────────────── */
|
||||||
.error-state {
|
.error-state {
|
||||||
@@ -397,9 +390,17 @@
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.topo-v2-wire-10g { width: 2px; height: 100%; background: var(--green); opacity: .55; }
|
.topo-v2-wire-10g { width: 2px; height: 100%; background: var(--green); opacity: .55; transition: background .3s, opacity .3s; }
|
||||||
.topo-v2-wire-1g { width: 0; height: 100%; border-left: 2px dashed var(--amber); opacity: .45; }
|
.topo-v2-wire-1g { width: 0; height: 100%; border-left: 2px dashed var(--amber); opacity: .45; }
|
||||||
|
|
||||||
|
@keyframes wire-dash-anim { to { background-position: 0 -20px; } }
|
||||||
|
.topo-v2-wire-10g.wire-down {
|
||||||
|
background: repeating-linear-gradient(to bottom, var(--red) 0 6px, transparent 6px 10px) !important;
|
||||||
|
background-size: 2px 10px !important;
|
||||||
|
opacity: .9 !important;
|
||||||
|
animation: wire-dash-anim .7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bus rails */
|
/* Bus rails */
|
||||||
.topo-bus-section {
|
.topo-bus-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -557,14 +558,11 @@
|
|||||||
.counter-zero { color: var(--green); }
|
.counter-zero { color: var(--green); }
|
||||||
.counter-nonzero { color: var(--red); text-shadow: var(--glow-red); }
|
.counter-nonzero { color: var(--red); text-shadow: var(--glow-red); }
|
||||||
|
|
||||||
/* Traffic bars */
|
/* Traffic bars — use lt-progress from base.css */
|
||||||
.traffic-section { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,107,0,.08); }
|
.traffic-section { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,107,0,.08); }
|
||||||
.traffic-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
|
.traffic-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
|
||||||
.traffic-label { font-size: .62em; color: var(--text-muted); width: 20px; text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
|
.traffic-label { font-size: .62em; color: var(--text-muted); width: 20px; text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
|
||||||
.traffic-bar-track{ flex: 1; height: 5px; background: var(--bg-primary); border: 1px solid rgba(255,107,0,.15); position: relative; overflow: hidden; }
|
.traffic-row .lt-progress { flex: 1; height: 5px; }
|
||||||
.traffic-bar-fill { height: 100%; position: absolute; left: 0; top: 0; transition: width .4s; }
|
|
||||||
.traffic-tx { background: var(--cyan); box-shadow: 0 0 3px rgba(0,212,255,.4); }
|
|
||||||
.traffic-rx { background: var(--green); box-shadow: 0 0 3px rgba(0,255,136,.4); }
|
|
||||||
.traffic-value { font-size: .7em; color: var(--text-dim); width: 68px; text-align: right; flex-shrink: 0; }
|
.traffic-value { font-size: .7em; color: var(--text-dim); width: 68px; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
/* SFP / optical panel */
|
/* SFP / optical panel */
|
||||||
@@ -665,7 +663,7 @@
|
|||||||
.link-loading { padding: 20px; text-align: center; color: var(--text-muted); font-size: .8em; }
|
.link-loading { padding: 20px; text-align: center; color: var(--text-muted); font-size: .8em; }
|
||||||
.link-loading::after { content: ' ...'; animation: blink 1s step-end infinite; }
|
.link-loading::after { content: ' ...'; animation: blink 1s step-end infinite; }
|
||||||
.link-no-data { padding: 14px; color: var(--text-muted); font-size: .78em; text-align: center; }
|
.link-no-data { padding: 14px; color: var(--text-muted); font-size: .78em; text-align: center; }
|
||||||
.stale-banner { margin-bottom: 12px; }
|
.lt-alert { margin-bottom: 12px; }
|
||||||
|
|
||||||
/* ── Inspector page ───────────────────────────────────────────────── */
|
/* ── Inspector page ───────────────────────────────────────────────── */
|
||||||
.inspector-layout {
|
.inspector-layout {
|
||||||
|
|||||||
@@ -138,8 +138,8 @@
|
|||||||
<div class="topo-v2-host-wrap">
|
<div class="topo-v2-host-wrap">
|
||||||
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
|
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
|
||||||
<div class="topo-v2-host-wires">
|
<div class="topo-v2-host-wires">
|
||||||
<div class="topo-v2-wire-10g" title="10G SFP+ → USW-Agg"></div>
|
<div class="topo-v2-wire-10g" data-host="{{ hname }}" title="10G SFP+ → USW-Agg"></div>
|
||||||
<div class="topo-v2-wire-1g" title="1G → Pro 24 PoE"></div>
|
<div class="topo-v2-wire-1g" data-host="{{ hname }}" title="1G → Pro 24 PoE"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- host box -->
|
<!-- host box -->
|
||||||
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}"
|
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}"
|
||||||
@@ -336,7 +336,7 @@
|
|||||||
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
|
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
|
||||||
data-sup-name="{{ e.target_name }}"
|
data-sup-name="{{ e.target_name }}"
|
||||||
data-sup-detail="{{ e.target_detail or '' }}"
|
data-sup-detail="{{ e.target_detail or '' }}"
|
||||||
title="Suppress">🔕</button>
|
title="Suppress" aria-label="Suppress alert for {{ e.target_name }}">🔕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -399,11 +399,11 @@
|
|||||||
<h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
|
<h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
|
||||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="suppress-form" onsubmit="submitSuppress(event)">
|
<form id="suppress-form">
|
||||||
<div class="lt-modal-body">
|
<div class="lt-modal-body">
|
||||||
<div class="lt-form-group" style="margin-bottom:12px">
|
<div class="lt-form-group" style="margin-bottom:12px">
|
||||||
<label class="lt-label" for="sup-type">Target Type</label>
|
<label class="lt-label" for="sup-type">Target Type</label>
|
||||||
<select class="lt-select" id="sup-type" name="target_type" onchange="updateSuppressForm()">
|
<select class="lt-select" id="sup-type" name="target_type">
|
||||||
<option value="host">Host (all interfaces)</option>
|
<option value="host">Host (all interfaces)</option>
|
||||||
<option value="interface">Specific Interface</option>
|
<option value="interface">Specific Interface</option>
|
||||||
<option value="unifi_device">UniFi Device</option>
|
<option value="unifi_device">UniFi Device</option>
|
||||||
@@ -449,6 +449,8 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
lt.autoRefresh.start(refreshAll, 30000);
|
lt.autoRefresh.start(refreshAll, 30000);
|
||||||
|
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
|
||||||
|
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
|
||||||
|
|
||||||
function updateEventAges() {
|
function updateEventAges() {
|
||||||
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
|
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function portBlockHtml(idx, port, swName, sfpBlock) {
|
|||||||
return `<div class="switch-port-block ${state}${sfpCls}"
|
return `<div class="switch-port-block ${state}${sfpCls}"
|
||||||
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
||||||
title="${title}"
|
title="${title}"
|
||||||
onclick="selectPort(this)"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
|
data-action="select-port"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chassis legend HTML ──────────────────────────────────────────────────
|
// ── Chassis legend HTML ──────────────────────────────────────────────────
|
||||||
@@ -169,8 +169,8 @@ function renderChassis(swName, sw) {
|
|||||||
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
|
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
|
||||||
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
|
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
|
||||||
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
||||||
title="${title}"
|
title="${title}" aria-label="${title}"
|
||||||
onclick="selectPort(this)"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
|
data-action="select-port"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
|
||||||
}
|
}
|
||||||
chassisHtml += '</div>';
|
chassisHtml += '</div>';
|
||||||
}
|
}
|
||||||
@@ -318,7 +318,7 @@ function renderPanel(swName, idx) {
|
|||||||
_apiData.hosts && _apiData.hosts[d.lldp.system_name]);
|
_apiData.hosts && _apiData.hosts[d.lldp.system_name]);
|
||||||
const diagHtml = hasDiagTarget ? `
|
const diagHtml = hasDiagTarget ? `
|
||||||
<div class="diag-bar">
|
<div class="diag-bar">
|
||||||
<button class="btn-diag" onclick="runDiagnostic('${escHtml(swName)}', ${idx})">Run Link Diagnostics</button>
|
<button class="btn-diag" data-action="run-diagnostic" data-sw="${escHtml(swName)}" data-idx="${idx}">Run Link Diagnostics</button>
|
||||||
<span class="diag-status" id="diag-status"></span>
|
<span class="diag-status" id="diag-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="diag-results" id="diag-results"></div>` : '';
|
<div class="diag-results" id="diag-results"></div>` : '';
|
||||||
@@ -330,7 +330,7 @@ function renderPanel(swName, idx) {
|
|||||||
<span class="panel-port-name">${escHtml(d.name)}</span>${isUplinkBadge}
|
<span class="panel-port-name">${escHtml(d.name)}</span>${isUplinkBadge}
|
||||||
<div class="panel-meta">${escHtml(swName)} · port #${idx}</div>
|
<div class="panel-meta">${escHtml(swName)} · port #${idx}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="panel-close" onclick="closePanel()">✕</button>
|
<button class="panel-close" data-action="close-panel" aria-label="Close panel">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-section-title">Link</div>
|
<div class="panel-section-title">Link</div>
|
||||||
@@ -468,6 +468,20 @@ lt.keys.on('Escape', () => {
|
|||||||
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
|
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const portBlock = e.target.closest('[data-action="select-port"]');
|
||||||
|
if (portBlock) { selectPort(portBlock); return; }
|
||||||
|
|
||||||
|
const closeBtn = e.target.closest('[data-action="close-panel"]');
|
||||||
|
if (closeBtn) { closePanel(); return; }
|
||||||
|
|
||||||
|
const diagBtn = e.target.closest('[data-action="run-diagnostic"]');
|
||||||
|
if (diagBtn) { runDiagnostic(diagBtn.dataset.sw, parseInt(diagBtn.dataset.idx, 10)); return; }
|
||||||
|
|
||||||
|
const toggleDiag = e.target.closest('[data-action="toggle-diag"]');
|
||||||
|
if (toggleDiag) { toggleDiag.parentElement.classList.toggle('diag-open'); return; }
|
||||||
|
});
|
||||||
|
|
||||||
// ── Link Diagnostics ─────────────────────────────────────────────────
|
// ── Link Diagnostics ─────────────────────────────────────────────────
|
||||||
let _diagPollTimer = null;
|
let _diagPollTimer = null;
|
||||||
|
|
||||||
@@ -635,7 +649,7 @@ function renderDiagnosticResults(d, container) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
nicStatHtml = `
|
nicStatHtml = `
|
||||||
<div class="diag-section diag-collapsible">
|
<div class="diag-section diag-collapsible">
|
||||||
<div class="diag-section-header diag-toggle" onclick="this.parentElement.classList.toggle('diag-open')">
|
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
|
||||||
ethtool -S (NIC stats) <span class="diag-toggle-hint">[expand]</span>
|
ethtool -S (NIC stats) <span class="diag-toggle-hint">[expand]</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="diag-section-body">
|
<div class="diag-section-body">
|
||||||
@@ -711,7 +725,7 @@ function renderDiagnosticResults(d, container) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
dmesgHtml = `
|
dmesgHtml = `
|
||||||
<div class="diag-section diag-collapsible">
|
<div class="diag-section diag-collapsible">
|
||||||
<div class="diag-section-header diag-toggle" onclick="this.parentElement.classList.toggle('diag-open')">
|
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
|
||||||
Kernel Events (dmesg) <span class="diag-toggle-hint">[expand]</span>
|
Kernel Events (dmesg) <span class="diag-toggle-hint">[expand]</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="diag-section-body">
|
<div class="diag-section-body">
|
||||||
|
|||||||
+25
-10
@@ -38,6 +38,11 @@ function fmtRateBar(bytesPerSec, linkSpeedMbps) {
|
|||||||
return Math.min(100, (mbps / linkSpeedMbps) * 100);
|
return Math.min(100, (mbps / linkSpeedMbps) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trafficBarClass(pct, isTx) {
|
||||||
|
if (pct > 85) return 'lt-progress--red';
|
||||||
|
return isTx ? '' : 'lt-progress--cyan';
|
||||||
|
}
|
||||||
|
|
||||||
function fmtSpeed(mbps) {
|
function fmtSpeed(mbps) {
|
||||||
if (mbps === null || mbps === undefined) return '–';
|
if (mbps === null || mbps === undefined) return '–';
|
||||||
if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps';
|
if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps';
|
||||||
@@ -226,12 +231,12 @@ function renderIfaceCard(ifaceName, d) {
|
|||||||
<div class="traffic-section">
|
<div class="traffic-section">
|
||||||
<div class="traffic-row">
|
<div class="traffic-row">
|
||||||
<span class="traffic-label">TX</span>
|
<span class="traffic-label">TX</span>
|
||||||
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
<div class="lt-progress ${trafficBarClass(txPct, true)}"><div class="lt-progress-bar" style="width:${txPct}%"></div></div>
|
||||||
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
|
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="traffic-row">
|
<div class="traffic-row">
|
||||||
<span class="traffic-label">RX</span>
|
<span class="traffic-label">RX</span>
|
||||||
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
<div class="lt-progress ${trafficBarClass(rxPct, false)}"><div class="lt-progress-bar" style="width:${rxPct}%"></div></div>
|
||||||
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
|
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,12 +294,12 @@ function renderPortCard(portName, d) {
|
|||||||
<div class="traffic-section">
|
<div class="traffic-section">
|
||||||
<div class="traffic-row">
|
<div class="traffic-row">
|
||||||
<span class="traffic-label">TX</span>
|
<span class="traffic-label">TX</span>
|
||||||
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
<div class="lt-progress ${trafficBarClass(txPct, true)}"><div class="lt-progress-bar" style="width:${txPct}%"></div></div>
|
||||||
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
|
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="traffic-row">
|
<div class="traffic-row">
|
||||||
<span class="traffic-label">RX</span>
|
<span class="traffic-label">RX</span>
|
||||||
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
<div class="lt-progress ${trafficBarClass(rxPct, false)}"><div class="lt-progress-bar" style="width:${rxPct}%"></div></div>
|
||||||
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
|
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +332,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="link-host-panel" id="panel-${CSS.escape(swName)}">
|
<div class="link-host-panel" id="panel-${CSS.escape(swName)}">
|
||||||
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
|
<div class="link-host-title" data-action="toggle-panel">
|
||||||
<span class="link-host-name">${escHtml(swName)}</span>
|
<span class="link-host-name">${escHtml(swName)}</span>
|
||||||
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
||||||
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
|
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
|
||||||
@@ -415,8 +420,8 @@ function renderLinks(data) {
|
|||||||
|
|
||||||
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
||||||
parts.push(`<div class="link-collapse-bar">
|
parts.push(`<div class="link-collapse-bar">
|
||||||
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="collapseAll()">Collapse All</button>
|
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="collapse-all">Collapse All</button>
|
||||||
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="expandAll()">Expand All</button>
|
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
|
||||||
</div>`);
|
</div>`);
|
||||||
parts.push('<div class="link-host-list">');
|
parts.push('<div class="link-host-list">');
|
||||||
|
|
||||||
@@ -432,7 +437,7 @@ function renderLinks(data) {
|
|||||||
|
|
||||||
parts.push(`
|
parts.push(`
|
||||||
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
|
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
|
||||||
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
|
<div class="link-host-title" data-action="toggle-panel">
|
||||||
<span class="link-host-name">${escHtml(hostname)}</span>
|
<span class="link-host-name">${escHtml(hostname)}</span>
|
||||||
<span class="link-host-ip">${escHtml(ip)}</span>
|
<span class="link-host-ip">${escHtml(ip)}</span>
|
||||||
<span class="link-host-upd">${updStr}</span>
|
<span class="link-host-upd">${updStr}</span>
|
||||||
@@ -477,10 +482,12 @@ function checkLinksStale(updatedStr) {
|
|||||||
if (!banner) {
|
if (!banner) {
|
||||||
banner = document.createElement('div');
|
banner = document.createElement('div');
|
||||||
banner.id = 'links-stale-banner';
|
banner.id = 'links-stale-banner';
|
||||||
banner.className = 'stale-banner';
|
banner.className = 'lt-alert lt-alert--warning';
|
||||||
|
banner.innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>';
|
||||||
document.getElementById('links-container').prepend(banner);
|
document.getElementById('links-container').prepend(banner);
|
||||||
}
|
}
|
||||||
banner.textContent = `⚠ Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
|
banner.querySelector('.lt-alert-msg').textContent =
|
||||||
|
`Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
|
||||||
banner.style.display = '';
|
banner.style.display = '';
|
||||||
} else if (banner) {
|
} else if (banner) {
|
||||||
banner.style.display = 'none';
|
banner.style.display = 'none';
|
||||||
@@ -511,5 +518,13 @@ async function loadLinks() {
|
|||||||
|
|
||||||
loadLinks();
|
loadLinks();
|
||||||
lt.autoRefresh.start(loadLinks, 60000);
|
lt.autoRefresh.start(loadLinks, 60000);
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
|
||||||
|
if (toggleTitle) { togglePanel(toggleTitle.closest('.link-host-panel')); return; }
|
||||||
|
|
||||||
|
if (e.target.closest('[data-action="collapse-all"]')) { collapseAll(); return; }
|
||||||
|
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="lt-card">
|
<div class="lt-card">
|
||||||
<div class="lt-card-body">
|
<div class="lt-card-body">
|
||||||
<form id="create-suppression-form" onsubmit="createSuppression(event)">
|
<form id="create-suppression-form">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="lt-form-group">
|
<div class="lt-form-group">
|
||||||
<label class="lt-label" for="s-type">Target Type <span class="required">*</span></label>
|
<label class="lt-label" for="s-type">Target Type <span class="required">*</span></label>
|
||||||
<select class="lt-select" id="s-type" name="target_type" onchange="onTypeChange()">
|
<select class="lt-select" id="s-type" name="target_type">
|
||||||
<option value="host">Host (all interfaces)</option>
|
<option value="host">Host (all interfaces)</option>
|
||||||
<option value="interface">Specific Interface</option>
|
<option value="interface">Specific Interface</option>
|
||||||
<option value="unifi_device">UniFi Device</option>
|
<option value="unifi_device">UniFi Device</option>
|
||||||
@@ -95,7 +95,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for s in active %}
|
{% for s in active %}
|
||||||
<tr id="sup-row-{{ s.id }}">
|
<tr id="sup-row-{{ s.id }}">
|
||||||
<td><span class="lt-badge badge-info">{{ s.target_type }}</span></td>
|
{%- set _sup_badge = {'host':'badge-warning','interface':'badge-info','unifi_device':'badge-purple','all':'badge-critical'} -%}
|
||||||
|
<td><span class="lt-badge {{ _sup_badge.get(s.target_type, 'badge-neutral') }}">{{ s.target_type }}</span></td>
|
||||||
<td>{{ s.target_name or 'all' }}</td>
|
<td>{{ s.target_name or 'all' }}</td>
|
||||||
<td>{{ s.target_detail or '–' }}</td>
|
<td>{{ s.target_detail or '–' }}</td>
|
||||||
<td>{{ s.reason }}</td>
|
<td>{{ s.reason }}</td>
|
||||||
@@ -214,9 +215,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (badge) badge.textContent = rows.length;
|
if (badge) badge.textContent = rows.length;
|
||||||
|
const SUP_BADGE = {host:'badge-warning', interface:'badge-info', unifi_device:'badge-purple', all:'badge-critical'};
|
||||||
const tbody = rows.map(s => `
|
const tbody = rows.map(s => `
|
||||||
<tr id="sup-row-${s.id}">
|
<tr id="sup-row-${s.id}">
|
||||||
<td><span class="lt-badge badge-info">${lt.escHtml(s.target_type)}</span></td>
|
<td><span class="lt-badge ${SUP_BADGE[s.target_type] || 'badge-neutral'}">${lt.escHtml(s.target_type)}</span></td>
|
||||||
<td>${lt.escHtml(s.target_name || 'all')}</td>
|
<td>${lt.escHtml(s.target_name || 'all')}</td>
|
||||||
<td>${lt.escHtml(s.target_detail || '–')}</td>
|
<td>${lt.escHtml(s.target_detail || '–')}</td>
|
||||||
<td>${lt.escHtml(s.reason)}</td>
|
<td>${lt.escHtml(s.reason)}</td>
|
||||||
@@ -294,6 +296,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('s-type')?.addEventListener('change', onTypeChange);
|
||||||
|
document.getElementById('create-suppression-form')?.addEventListener('submit', createSuppression);
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
const pill = e.target.closest('#create-suppression-form .pill[data-duration]');
|
const pill = e.target.closest('#create-suppression-form .pill[data-duration]');
|
||||||
if (pill) {
|
if (pill) {
|
||||||
|
|||||||
Reference in New Issue
Block a user