Compare commits
2 Commits
17d3b7d227
...
6b6eaa6227
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b6eaa6227 | |||
| 9c9acbb023 |
13
README.md
13
README.md
@@ -5,6 +5,19 @@
|
||||
Network monitoring dashboard for the LotusGuild Proxmox cluster.
|
||||
Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`.
|
||||
|
||||
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
||||
|
||||
## Styling & Layout
|
||||
|
||||
GANDALF uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
||||
|
||||
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
|
||||
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
|
||||
- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, auto-refresh, fetch helpers)
|
||||
- [`web_template/aesthetic_diff.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/aesthetic_diff.md) — cross-app divergence analysis and convergence guide
|
||||
- [`web_template/python/base.html`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/base.html) — Jinja2 base template
|
||||
- [`web_template/python/auth.py`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/auth.py) — `@require_auth` decorator pattern
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
// ── Toast notifications ───────────────────────────────────────────────
|
||||
// ── Toast notifications — delegates to lt.toast from base.js ─────────
|
||||
function showToast(msg, type = 'success') {
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = msg;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3500);
|
||||
if (type === 'error') return lt.toast.error(msg);
|
||||
if (type === 'warning') return lt.toast.warning(msg);
|
||||
if (type === 'info') return lt.toast.info(msg);
|
||||
return lt.toast.success(msg);
|
||||
}
|
||||
|
||||
// ── Dashboard auto-refresh ────────────────────────────────────────────
|
||||
@@ -179,7 +172,8 @@ function updateEventsTable(events) {
|
||||
<td><strong>${escHtml(e.target_name)}</strong></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="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>${ticket}</td>
|
||||
<td>
|
||||
@@ -197,7 +191,7 @@ function updateEventsTable(events) {
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
@@ -307,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 '';
|
||||
|
||||
1
static/base.css
Symbolic link
1
static/base.css
Symbolic link
@@ -0,0 +1 @@
|
||||
/root/code/web_template/base.css
|
||||
1
static/base.js
Symbolic link
1
static/base.js
Symbolic link
@@ -0,0 +1 @@
|
||||
/root/code/web_template/base.js
|
||||
132
static/style.css
132
static/style.css
@@ -35,11 +35,27 @@
|
||||
|
||||
--font: 'Courier New','Consolas','Monaco','Menlo',monospace;
|
||||
|
||||
--glow: 0 0 5px #00ff41, 0 0 10px rgba(0,255,65,.4);
|
||||
--glow-xl: 0 0 8px #00ff41, 0 0 20px rgba(0,255,65,.35);
|
||||
--glow-amber: 0 0 5px #ffb000, 0 0 10px rgba(255,176,0,.4);
|
||||
--glow: 0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41;
|
||||
--glow-xl: 0 0 8px #00ff41, 0 0 16px #00ff41, 0 0 24px #00ff41, 0 0 32px rgba(0,255,65,.5);
|
||||
--glow-amber: 0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000;
|
||||
--glow-red: 0 0 5px #ff4444, 0 0 10px rgba(255,68,68,.4);
|
||||
--glow-cyan: 0 0 5px #00ffff, 0 0 10px rgba(0,255,255,.35);
|
||||
|
||||
/* Unified naming aliases — matches base.css variable names */
|
||||
--bg-primary: var(--bg);
|
||||
--bg-secondary: var(--bg2);
|
||||
--bg-tertiary: var(--bg3);
|
||||
--terminal-green: var(--green);
|
||||
--terminal-green-dim: var(--green-dim);
|
||||
--terminal-amber: var(--amber);
|
||||
--terminal-amber-dim: var(--amber-dim);
|
||||
--terminal-cyan: var(--cyan);
|
||||
--terminal-red: var(--red);
|
||||
--text-primary: var(--text);
|
||||
--text-secondary: var(--text-dim);
|
||||
--border-color: var(--border);
|
||||
--glow-green: var(--glow);
|
||||
--font-mono: var(--font);
|
||||
}
|
||||
|
||||
/* ── Reset ────────────────────────────────────────────────────────── */
|
||||
@@ -60,7 +76,7 @@ body {
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
animation: flicker .25s ease-in-out 45s infinite;
|
||||
animation: flicker .25s ease-in-out 30s infinite;
|
||||
}
|
||||
|
||||
/* CRT scanline overlay */
|
||||
@@ -70,7 +86,7 @@ body::before {
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0,0,0,.13) 0px, rgba(0,0,0,.13) 1px,
|
||||
rgba(0,0,0,0.15) 0px, rgba(0,0,0,0.15) 1px,
|
||||
transparent 1px, transparent 2px
|
||||
);
|
||||
pointer-events: none;
|
||||
@@ -157,13 +173,20 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
||||
}
|
||||
.nav-link::before { content:'[ '; }
|
||||
.nav-link::after { content:' ]'; }
|
||||
.nav-link:hover, .nav-link.active {
|
||||
.nav-link:hover {
|
||||
color: var(--green);
|
||||
border-color: var(--border);
|
||||
background: var(--green-dim);
|
||||
text-shadow: var(--glow);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link.active {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
background: var(--amber-dim);
|
||||
text-shadow: var(--glow-amber);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header-right { display:flex; align-items:center; gap:10px; }
|
||||
.header-user { font-size: .78em; color: var(--text-muted); }
|
||||
@@ -193,7 +216,8 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
.section-title::before { content:'╠══ '; color:var(--green); text-shadow:var(--glow); }
|
||||
.section-title::before { content:'╠═══ '; color:var(--green); text-shadow:var(--glow); }
|
||||
.section-title::after { content:' ═══╣'; color:var(--green); text-shadow:var(--glow); }
|
||||
|
||||
.section-badge {
|
||||
font-size: .72em;
|
||||
@@ -478,8 +502,8 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid;
|
||||
padding: 5px 12px;
|
||||
border: 2px solid;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-size: .8em;
|
||||
@@ -489,10 +513,13 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
||||
background: transparent;
|
||||
transition: all .15s;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); }
|
||||
.btn::before { content: '[ '; }
|
||||
.btn::after { content: ' ]'; }
|
||||
.btn:hover { transform: translateY(-2px); }
|
||||
|
||||
.btn-primary { color:var(--green); border-color:var(--green); text-shadow:var(--glow); }
|
||||
.btn-primary::before { content:'> '; color:var(--amber); }
|
||||
.btn-primary::after { content:''; }
|
||||
.btn-primary:hover { background:var(--green-dim); box-shadow:var(--glow); }
|
||||
|
||||
.btn-secondary { color:var(--text-dim); border-color:var(--border); }
|
||||
@@ -508,7 +535,7 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
||||
font-family: var(--font);
|
||||
font-size: .7em;
|
||||
font-weight: bold;
|
||||
border: 1px solid;
|
||||
border: 2px solid;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
letter-spacing: .04em;
|
||||
@@ -531,15 +558,15 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--green);
|
||||
box-shadow: 0 0 30px rgba(0,255,65,.18);
|
||||
border: 3px double var(--green);
|
||||
box-shadow: 0 0 30px rgba(0,255,65,.2), 0 8px 40px rgba(0,0,0,.8);
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.modal::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
|
||||
.modal::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
|
||||
.modal::before { content:'╔'; position:absolute; top:-1px; left:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
|
||||
.modal::after { content:'╗'; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
@@ -600,7 +627,7 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border: 2px solid var(--border);
|
||||
font-family: var(--font);
|
||||
font-size: .8em;
|
||||
background: var(--bg3);
|
||||
@@ -1426,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;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}GANDALF{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none">
|
||||
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||
</div>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="header-brand">
|
||||
@@ -46,6 +50,7 @@
|
||||
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}"
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='base.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
<th>Detail</th>
|
||||
<th>Description</th>
|
||||
<th>First Seen</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Failures</th>
|
||||
<th>Ticket</th>
|
||||
<th>Actions</th>
|
||||
@@ -215,7 +216,12 @@
|
||||
<td><strong>{{ e.target_name }}</strong></td>
|
||||
<td>{{ e.target_detail or '–' }}</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>
|
||||
{% if e.ticket_id %}
|
||||
@@ -233,7 +239,7 @@
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -299,5 +305,26 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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
|
||||
? `<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 `
|
||||
<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-col">
|
||||
<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>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>
|
||||
${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}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -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