feat: UI improvements — event ages, error badges, PoE bars, mismatch detection
- events table: add Last Seen column; show relative times ("3h ago") with
absolute timestamp on hover; update updateEventsTable() in app.js to match
- links.html: add error/drop/flap alert badges to interface and port card headers
- links.html: PoE power bar (draw/max ratio with colour-coded fill) and poe_mode
- links.html: stale data warning banner when link_stats are >2 minutes old
- links.html: improved error handler shows HTTP status instead of generic message
- links.html: fix collapse state persisted to localStorage (was sessionStorage,
lost on browser restart); fix collapseAll/expandAll to also persist state
- inspector.html: duplex mismatch and speed mismatch warnings in path debug panel
- inspector.html: carrier changes added to server column of path debug
- style.css: new classes — .link-alert-badge, .poe-bar-*, .path-mismatch-alert,
.error-state; fix .stale-banner to use CSS variables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,18 @@ function portTypeLabel(pt) {
|
||||
return {label: pt, cls: 'type-copper'};
|
||||
}
|
||||
|
||||
// ── Error alert badge ─────────────────────────────────────────────
|
||||
function errorBadges(d) {
|
||||
const badges = [];
|
||||
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01)
|
||||
badges.push('<span class="link-alert-badge">ERR</span>');
|
||||
if ((d.tx_drops_rate || 0) > 0.1 || (d.rx_drops_rate || 0) > 0.1)
|
||||
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
||||
if ((d.carrier_changes || 0) > 10)
|
||||
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
||||
return badges.join('');
|
||||
}
|
||||
|
||||
// ── Render a single interface card ────────────────────────────────
|
||||
function renderIfaceCard(ifaceName, d) {
|
||||
const speed = fmtSpeed(d.speed_mbps);
|
||||
@@ -204,6 +216,7 @@ function renderIfaceCard(ifaceName, d) {
|
||||
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
||||
${speed !== '–' ? `<span class="link-iface-speed">${speed}</span>` : ''}
|
||||
${ptype.label !== '–' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
|
||||
${errorBadges(d)}
|
||||
</div>
|
||||
<div class="link-stats-grid">
|
||||
<div class="link-stat">
|
||||
@@ -278,8 +291,19 @@ function renderPortCard(portName, d) {
|
||||
|
||||
const lldpHtml = (d.lldp && d.lldp.system_name)
|
||||
? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name)}${d.lldp.port_id ? ' (' + escHtml(d.lldp.port_id) + ')' : ''}</div>` : '';
|
||||
const poeMaxHtml = (d.poe_class != null)
|
||||
? `<div class="port-poe-info">PoE class ${d.poe_class}${d.poe_max_power ? ' / max ' + d.poe_max_power.toFixed(1) + 'W' : ''}</div>` : '';
|
||||
|
||||
let poeMaxHtml = '';
|
||||
if (d.poe_class != null) {
|
||||
const poeDraw = d.poe_power || 0;
|
||||
const poeMax = d.poe_max_power || 0;
|
||||
const poePct = poeMax > 0 ? Math.min(100, (poeDraw / poeMax) * 100) : 0;
|
||||
const poeBarCls = poePct >= 80 ? 'poe-bar-crit' : poePct >= 60 ? 'poe-bar-warn' : 'poe-bar-ok';
|
||||
const poeMode = d.poe_mode ? ` · ${escHtml(d.poe_mode)}` : '';
|
||||
poeMaxHtml = `<div class="port-poe-info">
|
||||
PoE class ${d.poe_class}${poeMax > 0 ? ` · ${poeDraw.toFixed(1)}W / ${poeMax.toFixed(1)}W max${poeMode}` : poeMode}
|
||||
${poeMax > 0 ? `<div class="poe-bar-track"><div class="poe-bar-fill ${poeBarCls}" style="width:${poePct.toFixed(1)}%"></div></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const txRate = d.tx_bytes_rate;
|
||||
const rxRate = d.rx_bytes_rate;
|
||||
@@ -295,6 +319,7 @@ function renderPortCard(portName, d) {
|
||||
<span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span>
|
||||
${numBadge}${uplinkBadge}${poeBadge}
|
||||
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
|
||||
${errorBadges(d)}
|
||||
</div>
|
||||
${lldpHtml}${poeMaxHtml}
|
||||
<div class="link-stats-grid">
|
||||
@@ -390,14 +415,14 @@ function togglePanel(panel) {
|
||||
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[–]';
|
||||
const id = panel.id;
|
||||
if (id) {
|
||||
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
|
||||
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||
saved[id] = panel.classList.contains('collapsed');
|
||||
sessionStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCollapseState() {
|
||||
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
|
||||
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||
for (const [id, collapsed] of Object.entries(saved)) {
|
||||
if (!collapsed) continue;
|
||||
const panel = document.getElementById(id);
|
||||
@@ -414,8 +439,13 @@ function collapseAll() {
|
||||
panel.classList.add('collapsed');
|
||||
const btn = panel.querySelector('.panel-toggle');
|
||||
if (btn) btn.textContent = '[+]';
|
||||
const id = panel.id;
|
||||
if (id) {
|
||||
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||
saved[id] = true;
|
||||
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||
}
|
||||
});
|
||||
sessionStorage.setItem('gandalfCollapsed', '{}'); // let restore pick it up next time
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
@@ -423,8 +453,13 @@ function expandAll() {
|
||||
panel.classList.remove('collapsed');
|
||||
const btn = panel.querySelector('.panel-toggle');
|
||||
if (btn) btn.textContent = '[–]';
|
||||
const id = panel.id;
|
||||
if (id) {
|
||||
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||
saved[id] = false;
|
||||
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||
}
|
||||
});
|
||||
sessionStorage.setItem('gandalfCollapsed', '{}');
|
||||
}
|
||||
|
||||
// ── Render all hosts ──────────────────────────────────────────────
|
||||
@@ -482,16 +517,37 @@ function renderLinks(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stale data check ─────────────────────────────────────────────
|
||||
function checkLinksStale(updatedStr) {
|
||||
let banner = document.getElementById('links-stale-banner');
|
||||
if (!updatedStr) return;
|
||||
const ageMs = Date.now() - new Date(updatedStr.replace(' UTC', 'Z').replace(' ', 'T'));
|
||||
if (ageMs > 120000) { // >2 minutes
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'links-stale-banner';
|
||||
banner.className = 'stale-banner';
|
||||
document.getElementById('links-container').insertAdjacentElement('beforebegin', banner);
|
||||
}
|
||||
const mins = Math.floor(ageMs / 60000);
|
||||
banner.textContent = `⚠ Link data is stale — last update was ${mins} minute${mins !== 1 ? 's' : ''} ago.`;
|
||||
banner.style.display = '';
|
||||
} else if (banner) {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch and render ──────────────────────────────────────────────
|
||||
async function loadLinks() {
|
||||
try {
|
||||
const resp = await fetch('/api/links');
|
||||
if (!resp.ok) throw new Error('API error');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
renderLinks(data);
|
||||
checkLinksStale(data.updated);
|
||||
} catch(e) {
|
||||
document.getElementById('links-container').innerHTML =
|
||||
'<p class="empty-state">Failed to load link data.</p>';
|
||||
`<div class="error-state"><p>Failed to load link data: ${escHtml(e.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user