diff --git a/static/app.js b/static/app.js
index 42706e0..38d1280 100644
--- a/static/app.js
+++ b/static/app.js
@@ -172,7 +172,8 @@ function updateEventsTable(events) {
${escHtml(e.target_name)} |
${escHtml(e.target_detail || '–')} |
${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''} |
- ${escHtml(e.first_seen||'')} |
+ ${fmtRelTime(e.first_seen)} |
+ ${fmtRelTime(e.last_seen)} |
${e.consecutive_failures} |
${ticket} |
@@ -190,7 +191,7 @@ function updateEventsTable(events) {
| Sev | Type | Target | Detail |
- Description | First Seen | Failures | Ticket | Actions |
+ Description | First Seen | Last Seen | Failures | Ticket | Actions |
| ${rows}
@@ -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 ───────────────────────────────────────────────────────────
function escHtml(str) {
if (str === null || str === undefined) return '';
diff --git a/static/style.css b/static/style.css
index a45fae1..9735f8e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1453,13 +1453,74 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
/* ── Stale monitoring banner ──────────────────────────────────────── */
.stale-banner {
- background: rgba(255, 160, 0, 0.12);
- border: 1px solid var(--warning);
- border-left: 4px solid var(--warning);
- color: var(--warning);
+ background: var(--amber-dim);
+ border: 1px solid var(--amber);
+ border-left: 4px solid var(--amber);
+ color: var(--amber);
padding: 10px 16px;
- margin: 12px 16px 0;
+ margin: 12px 0 0;
font-size: 0.88em;
- font-family: var(--font-mono);
+ font-family: var(--font);
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;
+}
diff --git a/templates/index.html b/templates/index.html
index 498d3ba..3f8ce62 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -201,6 +201,7 @@
Detail |
Description |
First Seen |
+ Last Seen |
Failures |
Ticket |
Actions |
@@ -215,7 +216,12 @@
{{ e.target_name }} |
{{ e.target_detail or '–' }} |
{{ e.description | truncate(60) }} |
- {{ e.first_seen }} |
+
+ {{ e.first_seen }}
+ |
+
+ {{ e.last_seen }}
+ |
{{ e.consecutive_failures }} |
{% if e.ticket_id %}
@@ -233,7 +239,7 @@
{% endif %}
{% else %}
- | | No active alerts ✔ |
+ | No active alerts ✔ |
{% endfor %}
@@ -299,5 +305,26 @@
{% block scripts %}
{% endblock %}
diff --git a/templates/inspector.html b/templates/inspector.html
index f1aee6c..5caed04 100644
--- a/templates/inspector.html
+++ b/templates/inspector.html
@@ -326,8 +326,25 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
const swErrTx = (swPort.tx_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
+ ? `⚠ DUPLEX MISMATCH — Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}
`
+ : '';
+
+ // 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
+ ? `⚠ SPEED MISMATCH — Switch: ${fmtSpeed(swSpd)} · Server: ${fmtSpeed(svrSpd)}
`
+ : '';
+
return `
Path Debug ${escHtml(connType)}
+ ${duplexWarnHtml}${speedWarnHtml}
@@ -347,6 +364,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
RX${fmtRate(svrData.rx_bytes_rate)}
TX Err${fmtErrors(svrData.tx_errs_rate)}
RX Err${fmtErrors(svrData.rx_errs_rate)}
+ ${svrData.carrier_changes != null ? `
Carrier Chg${svrData.carrier_changes}
` : ''}
${sfpDomHtml}
`;
diff --git a/templates/links.html b/templates/links.html
index 853d3c7..5f7238b 100644
--- a/templates/links.html
+++ b/templates/links.html
@@ -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('ERR');
+ if ((d.tx_drops_rate || 0) > 0.1 || (d.rx_drops_rate || 0) > 0.1)
+ badges.push('DROP');
+ if ((d.carrier_changes || 0) > 10)
+ badges.push('FLAP');
+ 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) {
${escHtml(ifaceName)}
${speed !== '–' ? `${speed}` : ''}
${ptype.label !== '–' ? `${escHtml(ptype.label)}` : ''}
+ ${errorBadges(d)}
@@ -278,8 +291,19 @@ function renderPortCard(portName, d) {
const lldpHtml = (d.lldp && d.lldp.system_name)
? `
→ ${escHtml(d.lldp.system_name)}${d.lldp.port_id ? ' (' + escHtml(d.lldp.port_id) + ')' : ''}
` : '';
- const poeMaxHtml = (d.poe_class != null)
- ? `
PoE class ${d.poe_class}${d.poe_max_power ? ' / max ' + d.poe_max_power.toFixed(1) + 'W' : ''}
` : '';
+
+ 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 = `
+ PoE class ${d.poe_class}${poeMax > 0 ? ` · ${poeDraw.toFixed(1)}W / ${poeMax.toFixed(1)}W max${poeMode}` : poeMode}
+ ${poeMax > 0 ? `
` : ''}
+
`;
+ }
const txRate = d.tx_bytes_rate;
const rxRate = d.rx_bytes_rate;
@@ -295,6 +319,7 @@ function renderPortCard(portName, d) {
${speed}
${numBadge}${uplinkBadge}${poeBadge}
${media ? `
${escHtml(media)}` : ''}
+ ${errorBadges(d)}
${lldpHtml}${poeMaxHtml}
@@ -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 =
- '
Failed to load link data.
';
+ `
Failed to load link data: ${escHtml(e.message)}
`;
}
}