feat: link health summary, recently resolved panel, event duration
- dashboard: pass recent_resolved (last 24h, limit 10) to index template; render "Recently Resolved" section showing type, target, resolved time, and calculated duration (first_seen → resolved_at) - dashboard: event-age spans now also update via setInterval; duration shown for resolved events (e.g. "2h 15m") - links page: link health summary panel shows server iface count, error/flap counts, switch port up/down, PoE total draw/capacity bar; only shows problematic stats if non-zero; shows "All OK ✔" when clean - style.css: new classes for summary panel, resolved row/badge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
app.py
2
app.py
@@ -130,6 +130,7 @@ def index():
|
|||||||
last_check = db.get_state('last_check', 'Never')
|
last_check = db.get_state('last_check', 'Never')
|
||||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||||
suppressions = db.get_active_suppressions()
|
suppressions = db.get_active_suppressions()
|
||||||
|
recent_resolved = db.get_recent_resolved(hours=24, limit=10)
|
||||||
return render_template(
|
return render_template(
|
||||||
'index.html',
|
'index.html',
|
||||||
user=user,
|
user=user,
|
||||||
@@ -138,6 +139,7 @@ def index():
|
|||||||
snapshot=snapshot,
|
snapshot=snapshot,
|
||||||
last_check=last_check,
|
last_check=last_check,
|
||||||
suppressions=suppressions,
|
suppressions=suppressions,
|
||||||
|
recent_resolved=recent_resolved,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1524,3 +1524,73 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
font-size: .88em;
|
font-size: .88em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Link health summary panel ────────────────────────────────────── */
|
||||||
|
.link-summary-panel {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-summary-panel.link-summary-has-alerts {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-summary-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-summary-stat {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-summary-stat.lss-alert .lss-label {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lss-label {
|
||||||
|
display: block;
|
||||||
|
font-size: .62em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lss-value {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lss-sub {
|
||||||
|
font-size: .7em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Recently resolved table ──────────────────────────────────────── */
|
||||||
|
.row-resolved td {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-resolved {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: var(--border);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-badge-resolved {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: .65em;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,6 +250,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
|
||||||
|
{% if recent_resolved %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Recently Resolved</h2>
|
||||||
|
<span class="section-badge section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Sev</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
<th>Resolved</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in recent_resolved %}
|
||||||
|
<tr class="row-resolved">
|
||||||
|
<td><span class="badge badge-resolved">{{ e.severity }}</span></td>
|
||||||
|
<td>{{ e.event_type | replace('_', ' ') }}</td>
|
||||||
|
<td><strong>{{ e.target_name }}</strong></td>
|
||||||
|
<td>{{ e.target_detail or '–' }}</td>
|
||||||
|
<td class="ts-cell">
|
||||||
|
<span class="event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="ts-cell event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}">–</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
|
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
|
||||||
<div id="suppress-modal" class="modal-overlay" style="display:none">
|
<div id="suppress-modal" class="modal-overlay" style="display:none">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -326,5 +364,21 @@
|
|||||||
|
|
||||||
updateEventAges();
|
updateEventAges();
|
||||||
setInterval(updateEventAges, 60000);
|
setInterval(updateEventAges, 60000);
|
||||||
|
|
||||||
|
// ── Event duration (resolved_at - first_seen) ──────────────────
|
||||||
|
function fmtDuration(firstTs, resolvedTs) {
|
||||||
|
if (!firstTs || !resolvedTs) return '–';
|
||||||
|
const parse = s => new Date(s.replace(' UTC', 'Z').replace(' ', 'T'));
|
||||||
|
const secs = Math.floor((parse(resolvedTs) - parse(firstTs)) / 1000);
|
||||||
|
if (secs < 0) return '–';
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
if (secs < 3600) return `${Math.floor(secs/60)}m`;
|
||||||
|
if (secs < 86400) return `${Math.floor(secs/3600)}h ${Math.floor((secs%3600)/60)}m`;
|
||||||
|
return `${Math.floor(secs/86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.event-duration[data-first][data-resolved]').forEach(el => {
|
||||||
|
el.textContent = fmtDuration(el.dataset.first, el.dataset.resolved);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -462,6 +462,74 @@ function expandAll() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Link health summary ───────────────────────────────────────────
|
||||||
|
function buildLinkSummary(hosts, unifiSwitches) {
|
||||||
|
let svrTotal = 0, svrErrors = 0, svrFlap = 0;
|
||||||
|
let swTotal = 0, swUp = 0, swDown = 0, swErrors = 0;
|
||||||
|
let poeDrawW = 0, poeMaxW = 0;
|
||||||
|
|
||||||
|
for (const ifaces of Object.values(hosts)) {
|
||||||
|
for (const d of Object.values(ifaces)) {
|
||||||
|
svrTotal++;
|
||||||
|
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) svrErrors++;
|
||||||
|
if ((d.carrier_changes || 0) > 10) svrFlap++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const sw of Object.values(unifiSwitches || {})) {
|
||||||
|
for (const d of Object.values(sw.ports || {})) {
|
||||||
|
swTotal++;
|
||||||
|
if (d.up) swUp++; else swDown++;
|
||||||
|
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) swErrors++;
|
||||||
|
if (d.poe_power != null) poeDrawW += d.poe_power;
|
||||||
|
if (d.poe_max_power != null) poeMaxW += d.poe_max_power;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const poePct = poeMaxW > 0 ? (poeDrawW / poeMaxW * 100) : null;
|
||||||
|
const poeBarCls = poePct >= 80 ? 'poe-bar-crit' : poePct >= 60 ? 'poe-bar-warn' : 'poe-bar-ok';
|
||||||
|
const totalErrors = svrErrors + swErrors;
|
||||||
|
const hasAlerts = totalErrors > 0 || svrFlap > 0 || swDown > 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="link-summary-panel${hasAlerts ? ' link-summary-has-alerts' : ''}">
|
||||||
|
<div class="link-summary-grid">
|
||||||
|
<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">Server Ifaces</span>
|
||||||
|
<span class="lss-value">${svrTotal}</span>
|
||||||
|
</div>
|
||||||
|
${svrErrors > 0 ? `<div class="link-summary-stat lss-alert">
|
||||||
|
<span class="lss-label">Iface Errors</span>
|
||||||
|
<span class="lss-value val-crit">${svrErrors}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${svrFlap > 0 ? `<div class="link-summary-stat lss-alert">
|
||||||
|
<span class="lss-label">Flapping</span>
|
||||||
|
<span class="lss-value val-warn">${svrFlap}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${swTotal > 0 ? `<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">Switch Ports</span>
|
||||||
|
<span class="lss-value">${swUp}<span class="lss-sub">/${swTotal}</span></span>
|
||||||
|
</div>` : ''}
|
||||||
|
${swDown > 0 ? `<div class="link-summary-stat lss-alert">
|
||||||
|
<span class="lss-label">Ports Down</span>
|
||||||
|
<span class="lss-value val-crit">${swDown}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${swErrors > 0 ? `<div class="link-summary-stat lss-alert">
|
||||||
|
<span class="lss-label">Port Errors</span>
|
||||||
|
<span class="lss-value val-crit">${swErrors}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${poePct !== null ? `<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">PoE Load</span>
|
||||||
|
<span class="lss-value ${poeBarCls === 'poe-bar-crit' ? 'val-crit' : poeBarCls === 'poe-bar-warn' ? 'val-warn' : 'val-good'}">${poeDrawW.toFixed(0)}W<span class="lss-sub">/${poeMaxW.toFixed(0)}W</span></span>
|
||||||
|
<div class="poe-bar-track" style="margin-top:3px"><div class="poe-bar-fill ${poeBarCls}" style="width:${poePct.toFixed(1)}%"></div></div>
|
||||||
|
</div>` : ''}
|
||||||
|
${totalErrors === 0 && svrFlap === 0 && swDown === 0 ? `<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">Status</span>
|
||||||
|
<span class="lss-value val-good">All OK ✔</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render all hosts ──────────────────────────────────────────────
|
// ── Render all hosts ──────────────────────────────────────────────
|
||||||
function renderLinks(data) {
|
function renderLinks(data) {
|
||||||
const hosts = data.hosts || {};
|
const hosts = data.hosts || {};
|
||||||
@@ -498,6 +566,7 @@ function renderLinks(data) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
document.getElementById('links-container').innerHTML =
|
document.getElementById('links-container').innerHTML =
|
||||||
|
buildLinkSummary(hosts, unifi) +
|
||||||
`<div class="link-collapse-bar">
|
`<div class="link-collapse-bar">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">Collapse all</button>
|
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">Collapse all</button>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="expandAll()">Expand all</button>
|
<button class="btn btn-secondary btn-sm" onclick="expandAll()">Expand all</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user