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:
2026-03-14 21:46:11 -04:00
parent 9c9acbb023
commit 6b6eaa6227
5 changed files with 194 additions and 19 deletions

View File

@@ -172,7 +172,8 @@ function updateEventsTable(events) {
<td><strong>${escHtml(e.target_name)}</strong></td> <td><strong>${escHtml(e.target_name)}</strong></td>
<td>${escHtml(e.target_detail || '')}</td> <td>${escHtml(e.target_detail || '')}</td>
<td class="desc-cell" title="${escHtml(e.description || '')}">${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td> <td class="desc-cell" title="${escHtml(e.description || '')}">${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
<td class="ts-cell">${escHtml(e.first_seen||'')}</td> <td class="ts-cell" title="${escHtml(e.first_seen||'')}">${fmtRelTime(e.first_seen)}</td>
<td class="ts-cell" title="${escHtml(e.last_seen||'')}">${fmtRelTime(e.last_seen)}</td>
<td>${e.consecutive_failures}</td> <td>${e.consecutive_failures}</td>
<td>${ticket}</td> <td>${ticket}</td>
<td> <td>
@@ -190,7 +191,7 @@ function updateEventsTable(events) {
<thead> <thead>
<tr> <tr>
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th> <th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
<th>Description</th><th>First Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th> <th>Description</th><th>First Seen</th><th>Last Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
@@ -300,6 +301,18 @@ document.addEventListener('click', e => {
} }
}); });
// ── Relative time ─────────────────────────────────────────────────────
function fmtRelTime(tsStr) {
if (!tsStr) return '';
const d = new Date(tsStr.replace(' UTC', 'Z').replace(' ', 'T'));
if (isNaN(d)) return tsStr;
const secs = Math.floor((Date.now() - d) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs/60)}m ago`;
if (secs < 86400) return `${Math.floor(secs/3600)}h ago`;
return `${Math.floor(secs/86400)}d ago`;
}
// ── Utility ─────────────────────────────────────────────────────────── // ── Utility ───────────────────────────────────────────────────────────
function escHtml(str) { function escHtml(str) {
if (str === null || str === undefined) return ''; if (str === null || str === undefined) return '';

View File

@@ -1453,13 +1453,74 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
/* ── Stale monitoring banner ──────────────────────────────────────── */ /* ── Stale monitoring banner ──────────────────────────────────────── */
.stale-banner { .stale-banner {
background: rgba(255, 160, 0, 0.12); background: var(--amber-dim);
border: 1px solid var(--warning); border: 1px solid var(--amber);
border-left: 4px solid var(--warning); border-left: 4px solid var(--amber);
color: var(--warning); color: var(--amber);
padding: 10px 16px; padding: 10px 16px;
margin: 12px 16px 0; margin: 12px 0 0;
font-size: 0.88em; font-size: 0.88em;
font-family: var(--font-mono); font-family: var(--font);
border-radius: 2px; border-radius: 2px;
} }
/* ── Link alert badges (error/flap indicators) ────────────────────── */
.link-alert-badge {
display: inline-block;
font-size: .6em;
font-weight: bold;
padding: 1px 5px;
border-radius: 2px;
background: var(--red-dim);
color: var(--red);
border: 1px solid var(--red);
margin-left: 4px;
vertical-align: middle;
letter-spacing: .05em;
}
.link-alert-badge.link-alert-amber {
background: var(--amber-dim);
color: var(--amber);
border-color: var(--amber);
}
/* ── PoE utilisation bar ──────────────────────────────────────────── */
.poe-bar-track {
height: 3px;
background: var(--bg3);
border-radius: 2px;
margin-top: 3px;
overflow: hidden;
}
.poe-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.poe-bar-ok { background: var(--green); }
.poe-bar-warn { background: var(--amber); }
.poe-bar-crit { background: var(--red); }
/* ── Path mismatch alert ──────────────────────────────────────────── */
.path-mismatch-alert {
background: var(--amber-dim);
border-left: 3px solid var(--amber);
color: var(--amber);
padding: 4px 8px;
margin-bottom: 6px;
font-size: .72em;
border-radius: 2px;
}
/* ── Error state for data containers ─────────────────────────────── */
.error-state {
padding: 16px 20px;
border-left: 3px solid var(--red);
background: var(--red-dim);
color: var(--red);
border-radius: 2px;
font-size: .88em;
}

View File

@@ -201,6 +201,7 @@
<th>Detail</th> <th>Detail</th>
<th>Description</th> <th>Description</th>
<th>First Seen</th> <th>First Seen</th>
<th>Last Seen</th>
<th>Failures</th> <th>Failures</th>
<th>Ticket</th> <th>Ticket</th>
<th>Actions</th> <th>Actions</th>
@@ -215,7 +216,12 @@
<td><strong>{{ e.target_name }}</strong></td> <td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td> <td>{{ e.target_detail or '' }}</td>
<td class="desc-cell" title="{{ e.description | e }}">{{ e.description | truncate(60) }}</td> <td class="desc-cell" title="{{ e.description | e }}">{{ e.description | truncate(60) }}</td>
<td class="ts-cell">{{ e.first_seen }}</td> <td class="ts-cell" title="{{ e.first_seen }}">
<span class="event-age" data-ts="{{ e.first_seen }}">{{ e.first_seen }}</span>
</td>
<td class="ts-cell" title="{{ e.last_seen }}">
<span class="event-age" data-ts="{{ e.last_seen }}">{{ e.last_seen }}</span>
</td>
<td>{{ e.consecutive_failures }}</td> <td>{{ e.consecutive_failures }}</td>
<td> <td>
{% if e.ticket_id %} {% if e.ticket_id %}
@@ -233,7 +239,7 @@
</tr> </tr>
{% endif %} {% endif %}
{% else %} {% else %}
<tr><td colspan="9" class="empty-state">No active alerts ✔</td></tr> <tr><td colspan="10" class="empty-state">No active alerts ✔</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -299,5 +305,26 @@
{% block scripts %} {% block scripts %}
<script> <script>
setInterval(refreshAll, 30000); setInterval(refreshAll, 30000);
// ── Relative time display for event age cells ──────────────────
function fmtRelTime(tsStr) {
if (!tsStr) return '';
const d = new Date(tsStr.replace(' UTC', 'Z').replace(' ', 'T'));
if (isNaN(d)) return tsStr;
const secs = Math.floor((Date.now() - d) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs/60)}m ago`;
if (secs < 86400) return `${Math.floor(secs/3600)}h ago`;
return `${Math.floor(secs/86400)}d ago`;
}
function updateEventAges() {
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
el.textContent = fmtRelTime(el.dataset.ts);
});
}
updateEventAges();
setInterval(updateEventAges, 60000);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -326,8 +326,25 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
const swErrTx = (swPort.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good'; const swErrTx = (swPort.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
const swErrRx = (swPort.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good'; const swErrRx = (swPort.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
// Detect duplex mismatch (switch full_duplex vs server duplex string)
const swFull = swPort.full_duplex;
const svrFull = (svrData.duplex || '').toLowerCase().includes('full');
const duplexMismatch = swPort.up && svrData.duplex &&
((swFull && !svrFull) || (!swFull && svrFull));
const duplexWarnHtml = duplexMismatch
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH — Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
: '';
// Detect speed mismatch
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
const speedWarnHtml = speedMismatch
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${fmtSpeed(swSpd)} · Server: ${fmtSpeed(svrSpd)}</div>`
: '';
return ` return `
<div class="panel-section-title">Path Debug <span class="path-conn-type">${escHtml(connType)}</span></div> <div class="panel-section-title">Path Debug <span class="path-conn-type">${escHtml(connType)}</span></div>
${duplexWarnHtml}${speedWarnHtml}
<div class="path-debug-cols"> <div class="path-debug-cols">
<div class="path-col"> <div class="path-col">
<div class="path-col-header">Switch</div> <div class="path-col-header">Switch</div>
@@ -347,6 +364,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
<div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div> <div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div>
<div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div> <div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div>
<div class="path-row"><span>RX Err</span><span class="${svrErrRx}">${fmtErrors(svrData.rx_errs_rate)}</span></div> <div class="path-row"><span>RX Err</span><span class="${svrErrRx}">${fmtErrors(svrData.rx_errs_rate)}</span></div>
${svrData.carrier_changes != null ? `<div class="path-row"><span>Carrier Chg</span><span class="${(svrData.carrier_changes||0)>10?'val-crit':(svrData.carrier_changes||0)>2?'val-warn':'val-good'}">${svrData.carrier_changes}</span></div>` : ''}
${sfpDomHtml} ${sfpDomHtml}
</div> </div>
</div>`; </div>`;

View File

@@ -124,6 +124,18 @@ function portTypeLabel(pt) {
return {label: pt, cls: 'type-copper'}; 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 ──────────────────────────────── // ── Render a single interface card ────────────────────────────────
function renderIfaceCard(ifaceName, d) { function renderIfaceCard(ifaceName, d) {
const speed = fmtSpeed(d.speed_mbps); const speed = fmtSpeed(d.speed_mbps);
@@ -204,6 +216,7 @@ function renderIfaceCard(ifaceName, d) {
<span class="link-iface-name">${escHtml(ifaceName)}</span> <span class="link-iface-name">${escHtml(ifaceName)}</span>
${speed !== '' ? `<span class="link-iface-speed">${speed}</span>` : ''} ${speed !== '' ? `<span class="link-iface-speed">${speed}</span>` : ''}
${ptype.label !== '' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''} ${ptype.label !== '' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
${errorBadges(d)}
</div> </div>
<div class="link-stats-grid"> <div class="link-stats-grid">
<div class="link-stat"> <div class="link-stat">
@@ -278,8 +291,19 @@ function renderPortCard(portName, d) {
const lldpHtml = (d.lldp && d.lldp.system_name) 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>` : ''; ? `<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 txRate = d.tx_bytes_rate;
const rxRate = d.rx_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> <span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span>
${numBadge}${uplinkBadge}${poeBadge} ${numBadge}${uplinkBadge}${poeBadge}
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''} ${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
${errorBadges(d)}
</div> </div>
${lldpHtml}${poeMaxHtml} ${lldpHtml}${poeMaxHtml}
<div class="link-stats-grid"> <div class="link-stats-grid">
@@ -390,14 +415,14 @@ function togglePanel(panel) {
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]'; if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]';
const id = panel.id; const id = panel.id;
if (id) { if (id) {
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}'); const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = panel.classList.contains('collapsed'); saved[id] = panel.classList.contains('collapsed');
sessionStorage.setItem('gandalfCollapsed', JSON.stringify(saved)); localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
} }
} }
function restoreCollapseState() { function restoreCollapseState() {
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}'); const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
for (const [id, collapsed] of Object.entries(saved)) { for (const [id, collapsed] of Object.entries(saved)) {
if (!collapsed) continue; if (!collapsed) continue;
const panel = document.getElementById(id); const panel = document.getElementById(id);
@@ -414,8 +439,13 @@ function collapseAll() {
panel.classList.add('collapsed'); panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle'); const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]'; 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() { function expandAll() {
@@ -423,8 +453,13 @@ function expandAll() {
panel.classList.remove('collapsed'); panel.classList.remove('collapsed');
const btn = panel.querySelector('.panel-toggle'); const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]'; 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 ────────────────────────────────────────────── // ── 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 ────────────────────────────────────────────── // ── Fetch and render ──────────────────────────────────────────────
async function loadLinks() { async function loadLinks() {
try { try {
const resp = await fetch('/api/links'); 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(); const data = await resp.json();
renderLinks(data); renderLinks(data);
checkLinksStale(data.updated);
} catch(e) { } catch(e) {
document.getElementById('links-container').innerHTML = 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>`;
} }
} }