Compare commits

..

2 Commits

Author SHA1 Message Date
6b6eaa6227 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>
2026-03-14 21:46:11 -04:00
9c9acbb023 Apply LotusGuild design system convergence (aesthetic_diff.md)
CSS (style.css):
- §1: Add unified naming aliases (--terminal-green, --bg-primary, etc.)
- §2: Upgrade borders: modal 1px→3px double, btn/btn-sm/inputs 1px→2px
- §3: Add [ ] bracket decorations to .btn classes; primary keeps > prefix;
  hover lift -1px→-2px; padding 6px 14px→5px 12px
- §4: Fix glow definitions from 2-layer rgba to 3-layer solid stack
- §5: Section headers now symmetric ╠═══ TITLE ═══╣ (was one-sided)
- §6+§7: Modal border 3px double, corners ┌┐→╔╗, add glow shadow
- §11: Nav active state now amber tint (was green); hover remains green
- §15: Scanline opacity 0.13→0.15; flicker delay 45s→30s

JS (app.js):
- §18: Replace custom showToast() with lt.toast.* delegate wrapper

Templates (base.html):
- Load base.css and base.js (symlinked from web_template)
- Add lt-boot overlay for boot sequence animation (§13)

README: Remove completed pending convergence items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:40:20 -04:00
9 changed files with 262 additions and 47 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1 @@
/root/code/web_template/base.css

1
static/base.js Symbolic link
View File

@@ -0,0 +1 @@
/root/code/web_template/base.js

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 %}

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 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>`;

View File

@@ -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>`;
}
}