Compare commits

..

16 Commits

Author SHA1 Message Date
d576a0fe2d Auto-reload on auth timeout (401 response)
Wrap window.fetch so any 401 triggers window.location.reload(),
sending the browser back through the Authelia proxy to the login page.
Covers all pages since app.js is loaded by base.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:43:08 -04:00
271c3c4373 Exclude LXC IPs from link stats collection
Add links_exclude_ips to monitor config; collect() skips any Prometheus
instance whose IP is in that list, preventing LXC containers from
appearing on the links/inspector pages as phantom hosts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:39:47 -04:00
e2b65db2fc Add pagination to event queries, input validation, daily event purge
- get_active_events() now takes limit/offset (default 200) to cap unbounded queries
- count_active_events() added to return total for pagination display
- /api/events supports ?limit=, ?offset=, ?status= query params (max 1000)
- /api/status includes total_active count alongside paginated events list
- index() route passes total_active to template for server-side truncation notice
- Show "Showing X of Y" notice in dashboard when events are truncated
- Suppression POST validates: reason ≤500 chars, target_name/detail ≤255 chars
- _purge_old_jobs_loop runs purge_old_resolved_events(90d) once per day

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:32:32 -04:00
b80fda7cb2 Fix host filtering: only show/monitor configured hosts; add PBS
- _collect_snapshot() and _process_interfaces() now skip any Prometheus
  instance not explicitly listed in config.json hosts[]. LXC app servers
  (postgresql, matrix, etc.) report node_exporter metrics but are not
  infrastructure hosts Gandalf should display or alert on.
- Add PBS (10.10.10.3) to config hosts[] with prometheus_instance;
  remove from ping_hosts (node_exporter already running on PBS, now
  added to Prometheus scrape config as job pbs-node).
- The _instance_map membership check is now consistent across snapshot,
  alerting, and ethtool SSH collection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:17:40 -04:00
eb8c0ded5e Fix: only SSH into explicitly configured hosts for ethtool collection
LinkStatsCollector.collect() was SSHing into every host reporting
node_network_* metrics to Prometheus, including unrelated app servers
like postgresql and matrix. Add instance_map membership check so ethtool
collection via Pulse only runs on hosts defined in config.json.

Prometheus metrics (traffic rates, errors) are still collected for all
instances — only the SSH/ethtool step is gated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:35:21 -04:00
b29b70d88b Improve Pulse execution reliability: retry logic, better logging, SSH hardening
monitor.py / diagnose.py PulseClient.run_command:
- Add automatic single retry on submit failure, explicit Pulse failure
  (status=failed/timed_out), and poll timeout — handles transient SSH
  or Pulse hiccups without dropping the whole collection cycle
- Log execution_id and full Pulse URL on every failure so failed runs
  can be found in the Pulse UI immediately
- Handle 'timed_out' and 'cancelled' Pulse statuses explicitly (previously
  only 'failed' was caught; others would spin until local deadline)
- Poll every 2s instead of 1s to reduce Pulse API chatter

SSH command options (_ssh_batch + diagnose.py):
- Add BatchMode=yes: aborts immediately instead of hanging on a
  password prompt if key auth fails
- Add ServerAliveInterval=10 ServerAliveCountMax=2: SSH detects a
  hung remote command within ~20s instead of sitting silent until the
  45s Pulse timeout expires

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 09:19:07 -04:00
2c67944b4b Fix topology chain order and inspector SFP port width
Topology:
- Correct series layout: UDM-Pro → USW-Agg → Pro 24 PoE (not a fork)
- Remove CSS fork divs, replace with straight vertical connectors
- Labels: WAN · 10G SFP+ (UDM→Agg), 10G trunk (Agg→PoE)
- Remove ISL from legend (no parallel switch pair)

Inspector:
- Fix USW-Agg port blocks appearing narrower than other switches
- SFP ports in rows now use same width (34px) as copper ports;
  all-SFP switches like USL8A no longer look undersized

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:42:38 -04:00
e8314b5ba3 Fix topology diagram: replace SVG fork with CSS, fix line alignment
- Remove SVG fork with preserveAspectRatio="none" (caused line width
  distortion and stretched 10G DAC label like a tube TV)
- Replace with pure CSS .topo-fork: stem + horizontal bar + left/right
  drops, all absolutely positioned at consistent 2px width
- Use .topo-sw-row with two 50% halves so switch centres land at
  exactly 25% and 75% — matching fork drop positions mathematically
- ISL rendered via ::before/::after on .topo-sw-row (switch boxes
  with solid bg cover the line at their edges, leaving only the gap)
- Add .topo-sw-drops: two vertical stubs from switch centres to bus rails
- All lines are now exactly 2px, no distortion, no misalignment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:35:02 -04:00
3dce602938 Redesign topology diagram with dual-homed bus layout and improve inspector chassis
- Replace flat topology with tiered bus-bar layout: Internet → UDM-Pro → SVG fork → USW-Agg + Pro 24 PoE → dual-homed servers
- Show 10G VLAN90 (Ceph) bus from USW-Agg and 1G DHCP management bus from Pro 24 PoE per host
- Add per-host drop wires (solid 10G + dashed 1G) with correct rack positions
- Mark large1 as off-rack (dashed border), ZimaBoards as off-rack mon-01/mon-02
- Add topology legend, inter-switch 10G ISL indicator
- Add recently resolved events section (last 24h) to dashboard
- Add last_seen column and relative timestamps to events table
- Add stale data banner when monitoring data >15 min old
- Improve inspector chassis with port speed labels, LLDP neighbor info, mounting ears, chassis legend
- Add duplex/speed mismatch warnings and carrier changes to path debug panel
- Bump updateTopology() to handle both topo-v2-status-* and topo-status-* classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:22:19 -04:00
6eb21055ef fix: topology — reflect VLAN90 Ceph network and DHCP management separation
10G SFP+ ports on USW-Agg are VLAN90 (10.10.90.x/24, static IPs, Ceph storage).
1G ports on Pro 24 PoE are DHCP management. Update topology to show this:
- USW-Agg sublabel shows VLAN90 · 10.10.90.x (cyan)
- Pro 24 PoE sublabel shows DHCP mgmt (cyan)
- Host sublabels changed from "10G+1G" to "VLAN90" for the 10G Agg connection
- 1G management band label updated to "← 1G DHCP mgmt (Pro 24 PoE) →"
- Add .topo-vlan-tag CSS for cyan VLAN annotation on switch nodes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:10:17 -04:00
f2541eb45c fix: topology — all servers dual-homed 10G+1G, show mgmt band
All rack servers (and large1 on table) have both a 10G link to USW-Agg
and a 1G management link to Pro 24 PoE. Update topology:
- Move all 6 hosts into single row (including large1)
- Update sublabels to "10G+1G" for all nodes
- large1 dashed-border (off-rack) with "table · 10G+1G"
- Add dashed amber "1G mgmt (PoE)" horizontal band above hosts
  to represent the PoE switch management connections
- 10G primary fan-out lines still drop from Agg switch above
- large1 primary line rendered as dashed green (off-rack run)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:08:48 -04:00
e779b21db4 feat: redesign network topology diagram with accurate rack layout
Replace linear Internet→UDM→Agg→PoE→all-hosts chain with accurate topology:
- USW-Aggregation and Pro 24 PoE switch shown side-by-side with horizontal
  10G SFP+ link between them (not in series)
- 5 compute/storage/monitor nodes fanned out under Agg Switch with 10G labels
  and rack unit positions (RU4–12, RU14–17) as sublabels
- large1 shown separately under PoE switch, dashed border = off-rack (table)
- Add device specs as subtitles on all nodes (Dream Machine Pro · RU24, etc.)
- Shorter display names: csg-01 / cs-01 instead of full hostnames
- Live status badges still updated by JS via data-host attributes
- New CSS: .topo-node-sub, .topo-switch-tier, .topo-h-link, .topo-host-tier,
  .topo-host-table (dashed), .topo-badge-unknown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:06:03 -04:00
c1fd53f9bd Remove aesthetic_diff.md reference from README — convergence complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:50:02 -04:00
0ca6b1f744 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>
2026-03-14 21:48:40 -04:00
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
14 changed files with 1406 additions and 125 deletions

View File

@@ -5,6 +5,18 @@
Network monitoring dashboard for the LotusGuild Proxmox cluster. Network monitoring dashboard for the LotusGuild Proxmox cluster.
Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`. 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/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 ## Architecture

53
app.py
View File

@@ -47,8 +47,11 @@ _diag_jobs: dict = {}
_diag_lock = threading.Lock() _diag_lock = threading.Lock()
_last_event_purge = [0.0] # mutable container so the thread can update it
def _purge_old_jobs_loop(): def _purge_old_jobs_loop():
"""Background thread: remove jobs older than 10 minutes and mark stuck running jobs as errored.""" """Background thread: remove stale diag jobs and run daily event purge."""
while True: while True:
time.sleep(120) time.sleep(120)
cutoff = time.time() - 600 cutoff = time.time() - 600
@@ -63,6 +66,15 @@ def _purge_old_jobs_loop():
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'} j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored') logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored')
# Purge old resolved events once per day
now = time.time()
if now - _last_event_purge[0] > 86400:
try:
db.purge_old_resolved_events(days=90)
except Exception as e:
logger.error(f'Daily event purge failed: {e}')
_last_event_purge[0] = now
_purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True) _purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True)
_purge_thread.start() _purge_thread.start()
@@ -120,24 +132,31 @@ def require_auth(f):
# Page routes # Page routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_PAGE_LIMIT = 200 # max events returned per request
@app.route('/') @app.route('/')
@require_auth @require_auth
def index(): def index():
user = _get_user() user = _get_user()
events = db.get_active_events() events = db.get_active_events(limit=_PAGE_LIMIT)
total_active = db.count_active_events()
summary = db.get_status_summary() summary = db.get_status_summary()
snapshot_raw = db.get_state('network_snapshot') snapshot_raw = db.get_state('network_snapshot')
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,
events=events, events=events,
total_active=total_active,
summary=summary, summary=summary,
snapshot=snapshot, snapshot=snapshot,
last_check=last_check, last_check=last_check,
suppressions=suppressions, suppressions=suppressions,
recent_resolved=recent_resolved,
) )
@@ -179,10 +198,12 @@ def suppressions_page():
@app.route('/api/status') @app.route('/api/status')
@require_auth @require_auth
def api_status(): def api_status():
active = db.get_active_events(limit=_PAGE_LIMIT)
return jsonify({ return jsonify({
'summary': db.get_status_summary(), 'summary': db.get_status_summary(),
'last_check': db.get_state('last_check', 'Never'), 'last_check': db.get_state('last_check', 'Never'),
'events': db.get_active_events(), 'events': active,
'total_active': db.count_active_events(),
}) })
@@ -213,10 +234,22 @@ def api_links():
@app.route('/api/events') @app.route('/api/events')
@require_auth @require_auth
def api_events(): def api_events():
return jsonify({ try:
'active': db.get_active_events(), limit = min(int(request.args.get('limit', _PAGE_LIMIT)), 1000)
'resolved': db.get_recent_resolved(hours=24, limit=30), offset = max(int(request.args.get('offset', 0)), 0)
}) except ValueError:
return jsonify({'error': 'limit and offset must be integers'}), 400
status_filter = request.args.get('status', 'active')
if status_filter not in ('active', 'resolved', 'all'):
return jsonify({'error': 'status must be active, resolved, or all'}), 400
result: dict = {}
if status_filter in ('active', 'all'):
result['active'] = db.get_active_events(limit=limit, offset=offset)
result['total_active'] = db.count_active_events()
if status_filter in ('resolved', 'all'):
result['resolved'] = db.get_recent_resolved(hours=24, limit=30)
return jsonify(result)
@app.route('/api/suppressions', methods=['GET']) @app.route('/api/suppressions', methods=['GET'])
@@ -243,6 +276,12 @@ def api_create_suppression():
return jsonify({'error': 'target_name required'}), 400 return jsonify({'error': 'target_name required'}), 400
if not reason: if not reason:
return jsonify({'error': 'reason required'}), 400 return jsonify({'error': 'reason required'}), 400
if len(reason) > 500:
return jsonify({'error': 'reason must be 500 characters or fewer'}), 400
if len(target_name) > 255:
return jsonify({'error': 'target_name must be 255 characters or fewer'}), 400
if len(target_detail) > 255:
return jsonify({'error': 'target_detail must be 255 characters or fewer'}), 400
sup_id = db.create_suppression( sup_id = db.create_suppression(
target_type=target_type, target_type=target_type,

View File

@@ -1,9 +1,9 @@
{ {
"ssh": { "pulse": {
"user": "root", "url": "http://10.10.10.65:8080",
"password": "Server#980000Panda", "api_key": "012b303a324152c509bf5ade6f942cfc21404f68662f01a17001cba9e4486049",
"connect_timeout": 5, "worker_id": "1b11d1b5-4ed0-42df-a6af-8d57fffe1343",
"timeout": 20 "timeout": 45
}, },
"unifi": { "unifi": {
"controller": "https://10.10.10.1", "controller": "https://10.10.10.1",
@@ -28,14 +28,18 @@
"allowed_groups": ["admin"] "allowed_groups": ["admin"]
}, },
"monitor": { "monitor": {
"poll_interval": 120, "poll_interval": 300,
"failure_threshold": 2, "failure_threshold": 2,
"cluster_threshold": 3, "cluster_threshold": 3,
"ping_hosts": [ "ping_hosts": [],
{"name": "pbs", "ip": "10.10.10.3"} "links_exclude_ips": ["10.10.10.29", "10.10.10.44", "10.10.10.3"]
]
}, },
"hosts": [ "hosts": [
{
"name": "pbs",
"ip": "10.10.10.3",
"prometheus_instance": "10.10.10.3:9100"
},
{ {
"name": "large1", "name": "large1",
"ip": "10.10.10.2", "ip": "10.10.10.2",

16
db.py
View File

@@ -153,7 +153,7 @@ def set_ticket_id(event_id: int, ticket_id: str) -> None:
) )
def get_active_events() -> list: def get_active_events(limit: int = 200, offset: int = 0) -> list:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
@@ -161,7 +161,9 @@ def get_active_events() -> list:
WHERE resolved_at IS NULL WHERE resolved_at IS NULL
ORDER BY ORDER BY
FIELD(severity,'critical','warning','info'), FIELD(severity,'critical','warning','info'),
first_seen DESC""" first_seen DESC
LIMIT %s OFFSET %s""",
(limit, offset),
) )
rows = cur.fetchall() rows = cur.fetchall()
for r in rows: for r in rows:
@@ -171,6 +173,16 @@ def get_active_events() -> list:
return rows return rows
def count_active_events() -> int:
"""Return count of all unresolved events (for pagination)."""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) AS n FROM network_events WHERE resolved_at IS NULL"
)
return cur.fetchone()['n']
def get_recent_resolved(hours: int = 24, limit: int = 50) -> list: def get_recent_resolved(hours: int = 24, limit: int = 50) -> list:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:

View File

@@ -77,7 +77,9 @@ class DiagnosticsRunner:
return ( return (
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 ' f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
f'-o LogLevel=ERROR root@{ip_q} \'{remote_cmd}\'' f'-o BatchMode=yes -o LogLevel=ERROR '
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
f'root@{ip_q} \'{remote_cmd}\''
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -246,11 +246,16 @@ class PulseClient:
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}) })
def run_command(self, command: str) -> Optional[str]: def run_command(self, command: str, _retry: bool = True) -> Optional[str]:
"""Submit *command* to Pulse, poll until done, return stdout or None.""" """Submit *command* to Pulse, poll until done, return stdout or None.
Retries once automatically on transient submit failures or timeouts.
"""
self.last_execution_id = None self.last_execution_id = None
if not self.url or not self.api_key or not self.worker_id: if not self.url or not self.api_key or not self.worker_id:
return None return None
# Submit
try: try:
resp = self.session.post( resp = self.session.post(
f'{self.url}/api/internal/command', f'{self.url}/api/internal/command',
@@ -262,11 +267,16 @@ class PulseClient:
self.last_execution_id = execution_id self.last_execution_id = execution_id
except Exception as e: except Exception as e:
logger.error(f'Pulse command submit failed: {e}') logger.error(f'Pulse command submit failed: {e}')
if _retry:
logger.info('Retrying Pulse command submit in 5s...')
time.sleep(5)
return self.run_command(command, _retry=False)
return None return None
# Poll
deadline = time.time() + self.timeout deadline = time.time() + self.timeout
while time.time() < deadline: while time.time() < deadline:
time.sleep(1) time.sleep(2)
try: try:
r = self.session.get( r = self.session.get(
f'{self.url}/api/internal/executions/{execution_id}', f'{self.url}/api/internal/executions/{execution_id}',
@@ -281,11 +291,28 @@ class PulseClient:
if entry.get('action') == 'command_result': if entry.get('action') == 'command_result':
return entry.get('stdout', '') return entry.get('stdout', '')
return '' return ''
if status == 'failed': if status in ('failed', 'timed_out', 'cancelled'):
logger.error(
f'Pulse execution {execution_id} ended with status={status!r}; '
f'view at {self.url}/executions/{execution_id}'
)
if _retry and status != 'cancelled':
logger.info('Retrying failed Pulse command in 5s...')
time.sleep(5)
return self.run_command(command, _retry=False)
return None return None
except Exception as e: except Exception as e:
logger.error(f'Pulse poll failed: {e}') logger.error(f'Pulse poll failed for {execution_id}: {e}')
logger.warning(f'Pulse command timed out after {self.timeout}s')
logger.warning(
f'Pulse command timed out after {self.timeout}s '
f'(execution_id={execution_id}); '
f'view at {self.url}/executions/{execution_id}'
)
if _retry:
logger.info('Retrying timed-out Pulse command in 5s...')
time.sleep(5)
return self.run_command(command, _retry=False)
return None return None
@@ -336,7 +363,9 @@ class LinkStatsCollector:
ssh_cmd = ( ssh_cmd = (
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 ' f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
f'-o LogLevel=ERROR root@{ip} "{shell_cmd}"' f'-o BatchMode=yes -o LogLevel=ERROR '
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
f'root@{ip} "{shell_cmd}"'
) )
output = self.pulse.run_command(ssh_cmd) output = self.pulse.run_command(ssh_cmd)
if output is None: if output is None:
@@ -524,15 +553,20 @@ class LinkStatsCollector:
""" """
prom_metrics = self._collect_prom_metrics() prom_metrics = self._collect_prom_metrics()
result_hosts: Dict[str, Dict[str, dict]] = {} result_hosts: Dict[str, Dict[str, dict]] = {}
exclude_ips = set(self.cfg.get('monitor', {}).get('links_exclude_ips', []))
for instance, iface_metrics in prom_metrics.items(): for instance, iface_metrics in prom_metrics.items():
host = instance_map.get(instance, instance.split(':')[0])
host_ip = instance.split(':')[0] host_ip = instance.split(':')[0]
if host_ip in exclude_ips:
continue
host = instance_map.get(instance, host_ip)
ifaces = list(iface_metrics.keys()) ifaces = list(iface_metrics.keys())
# SSH ethtool collection via Pulse worker (one connection per host, all ifaces) # SSH ethtool collection via Pulse worker — only for explicitly configured
# hosts (instance_map keys). Hosts like postgresql/matrix may report
# node_exporter metrics to Prometheus but don't need link diagnostics.
ethtool_data: Dict[str, dict] = {} ethtool_data: Dict[str, dict] = {}
if self.pulse.url and ifaces: if self.pulse.url and ifaces and instance in instance_map:
try: try:
ethtool_data = self._ssh_batch(host_ip, ifaces) ethtool_data = self._ssh_batch(host_ip, ifaces)
except Exception as e: except Exception as e:
@@ -663,6 +697,8 @@ class NetworkMonitor:
hosts_with_regression: List[str] = [] hosts_with_regression: List[str] = []
for instance, ifaces in states.items(): for instance, ifaces in states.items():
if instance not in self._instance_map:
continue # skip unconfigured Prometheus instances
host = self._hostname(instance) host = self._hostname(instance)
new_baseline.setdefault(host, {}) new_baseline.setdefault(host, {})
host_has_regression = False host_has_regression = False
@@ -846,6 +882,8 @@ class NetworkMonitor:
hosts = {} hosts = {}
for instance, ifaces in iface_states.items(): for instance, ifaces in iface_states.items():
if instance not in self._instance_map:
continue # skip Prometheus instances not in config (e.g. LXC app servers)
host = self._hostname(instance) host = self._hostname(instance)
phys = {k: v for k, v in ifaces.items()} phys = {k: v for k, v in ifaces.items()}
up_count = sum(1 for v in phys.values() if v) up_count = sum(1 for v in phys.values() if v)

View File

@@ -1,18 +1,25 @@
'use strict'; 'use strict';
// ── Toast notifications ─────────────────────────────────────────────── // ── Auto-redirect on auth timeout ─────────────────────────────────────
// Intercept all fetch() calls: if the server returns 401 (auth expired),
// reload the page so Authelia redirects to the login screen.
(function () {
const _fetch = window.fetch;
window.fetch = async function (...args) {
const resp = await _fetch(...args);
if (resp.status === 401) {
window.location.reload();
}
return resp;
};
})();
// ── Toast notifications — delegates to lt.toast from base.js ─────────
function showToast(msg, type = 'success') { function showToast(msg, type = 'success') {
let container = document.querySelector('.toast-container'); if (type === 'error') return lt.toast.error(msg);
if (!container) { if (type === 'warning') return lt.toast.warning(msg);
container = document.createElement('div'); if (type === 'info') return lt.toast.info(msg);
container.className = 'toast-container'; return lt.toast.success(msg);
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3500);
} }
// ── Dashboard auto-refresh ──────────────────────────────────────────── // ── Dashboard auto-refresh ────────────────────────────────────────────
@@ -29,7 +36,7 @@ async function refreshAll() {
updateHostGrid(net.hosts || {}); updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []); updateUnifiTable(net.unifi || []);
updateEventsTable(status.events || []); updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || ''); updateStatusBar(status.summary || {}, status.last_check || '');
updateTopology(net.hosts || {}); updateTopology(net.hosts || {});
@@ -116,7 +123,9 @@ function updateTopology(hosts) {
const name = node.dataset.host; const name = node.dataset.host;
const host = hosts[name]; const host = hosts[name];
if (!host) return; if (!host) return;
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, '');
node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, ''); node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, '');
node.classList.add(`topo-v2-status-${host.status}`);
node.classList.add(`topo-status-${host.status}`); node.classList.add(`topo-status-${host.status}`);
const badge = node.querySelector('.topo-badge'); const badge = node.querySelector('.topo-badge');
if (badge) { if (badge) {
@@ -152,7 +161,7 @@ function updateUnifiTable(devices) {
}).join(''); }).join('');
} }
function updateEventsTable(events) { function updateEventsTable(events, totalActive) {
const wrap = document.getElementById('events-table-wrap'); const wrap = document.getElementById('events-table-wrap');
if (!wrap) return; if (!wrap) return;
@@ -162,6 +171,11 @@ function updateEventsTable(events) {
return; return;
} }
const truncated = totalActive != null && totalActive > active.length;
const countNotice = truncated
? `<div class="pagination-notice">Showing ${active.length} of ${totalActive} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>`
: '';
const rows = active.map(e => { const rows = active.map(e => {
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device' const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
: e.event_type === 'interface_down' ? 'interface' : e.event_type === 'interface_down' ? 'interface'
@@ -179,7 +193,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>
@@ -192,12 +207,13 @@ function updateEventsTable(events) {
}).join(''); }).join('');
wrap.innerHTML = ` wrap.innerHTML = `
${countNotice}
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="events-table"> <table class="data-table" id="events-table">
<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>
@@ -307,6 +323,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 '';

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; --font: 'Courier New','Consolas','Monaco','Menlo',monospace;
--glow: 0 0 5px #00ff41, 0 0 10px rgba(0,255,65,.4); --glow: 0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41;
--glow-xl: 0 0 8px #00ff41, 0 0 20px rgba(0,255,65,.35); --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 rgba(255,176,0,.4); --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-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); --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 ────────────────────────────────────────────────────────── */ /* ── Reset ────────────────────────────────────────────────────────── */
@@ -60,7 +76,7 @@ body {
line-height: 1.5; line-height: 1.5;
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
animation: flicker .25s ease-in-out 45s infinite; animation: flicker .25s ease-in-out 30s infinite;
} }
/* CRT scanline overlay */ /* CRT scanline overlay */
@@ -70,7 +86,7 @@ body::before {
inset: 0; inset: 0;
background: repeating-linear-gradient( background: repeating-linear-gradient(
0deg, 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 transparent 1px, transparent 2px
); );
pointer-events: none; pointer-events: none;
@@ -157,13 +173,20 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
} }
.nav-link::before { content:'[ '; } .nav-link::before { content:'[ '; }
.nav-link::after { content:' ]'; } .nav-link::after { content:' ]'; }
.nav-link:hover, .nav-link.active { .nav-link:hover {
color: var(--green); color: var(--green);
border-color: var(--border); border-color: var(--border);
background: var(--green-dim); background: var(--green-dim);
text-shadow: var(--glow); text-shadow: var(--glow);
text-decoration: none; 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-right { display:flex; align-items:center; gap:10px; }
.header-user { font-size: .78em; color: var(--text-muted); } .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; text-transform: uppercase;
letter-spacing: .1em; 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 { .section-badge {
font-size: .72em; font-size: .72em;
@@ -332,6 +356,103 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
.topo-status-dot { width:7px; height:7px; border:1px solid var(--text-muted); background:transparent; position:absolute; top:5px; right:5px; } .topo-status-dot { width:7px; height:7px; border:1px solid var(--text-muted); background:transparent; position:absolute; top:5px; right:5px; }
/* Topology subtitle text */
.topo-node-sub {
font-size: .58em;
color: var(--text-muted);
letter-spacing: .02em;
font-weight: normal;
}
.topo-badge-unknown { color:var(--text-muted); border-color:var(--border); }
.topo-vlan-tag {
color: var(--cyan) !important;
opacity: .7;
font-size: .54em !important;
}
/* Switch tier: two switches with horizontal connector */
.topo-switch-tier {
display: flex;
align-items: center;
gap: 0;
}
.topo-h-link {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 8px;
}
.topo-h-link-line {
width: 70px;
height: 1px;
background: var(--amber);
opacity: .5;
}
.topo-h-link-label {
font-size: .52em;
color: var(--amber);
opacity: .7;
margin-top: 3px;
letter-spacing: .04em;
}
/* Host tier: two groups side by side */
.topo-host-tier {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 40px;
margin-top: 0;
}
.topo-host-group { flex-shrink: 0; }
/* PoE host group: offset right to sit below PoE switch */
.topo-poe-hosts {
padding-top: 0;
}
/* Off-rack node (dashed border) */
.topo-host-table {
border-style: dashed;
}
/* Dashed 10G line (for off-rack/table host) */
.topo-line-dashed {
background: none;
border-left: 1px dashed var(--green);
opacity: .4;
}
/* 1G management band — horizontal amber dashed line with label */
.topo-mgmt-band {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 16px;
}
.topo-mgmt-label {
font-size: .52em;
color: var(--amber);
opacity: .65;
white-space: nowrap;
letter-spacing: .04em;
}
.topo-mgmt-line {
flex: 1;
height: 1px;
border-top: 1px dashed var(--amber);
opacity: .4;
}
/* ── Host cards ───────────────────────────────────────────────────── */ /* ── Host cards ───────────────────────────────────────────────────── */
.host-grid { .host-grid {
display: grid; display: grid;
@@ -478,8 +599,8 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding: 6px 14px; padding: 5px 12px;
border: 1px solid; border: 2px solid;
cursor: pointer; cursor: pointer;
font-family: var(--font); font-family: var(--font);
font-size: .8em; font-size: .8em;
@@ -489,10 +610,13 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
background: transparent; background: transparent;
transition: all .15s; 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 { color:var(--green); border-color:var(--green); text-shadow:var(--glow); }
.btn-primary::before { content:'> '; color:var(--amber); } .btn-primary::before { content:'> '; color:var(--amber); }
.btn-primary::after { content:''; }
.btn-primary:hover { background:var(--green-dim); box-shadow:var(--glow); } .btn-primary:hover { background:var(--green-dim); box-shadow:var(--glow); }
.btn-secondary { color:var(--text-dim); border-color:var(--border); } .btn-secondary { color:var(--text-dim); border-color:var(--border); }
@@ -508,7 +632,7 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
font-family: var(--font); font-family: var(--font);
font-size: .7em; font-size: .7em;
font-weight: bold; font-weight: bold;
border: 1px solid; border: 2px solid;
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
letter-spacing: .04em; letter-spacing: .04em;
@@ -531,15 +655,15 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
} }
.modal { .modal {
background: var(--bg2); background: var(--bg2);
border: 1px solid var(--green); border: 3px double var(--green);
box-shadow: 0 0 30px rgba(0,255,65,.18); box-shadow: 0 0 30px rgba(0,255,65,.2), 0 8px 40px rgba(0,0,0,.8);
width: 480px; width: 480px;
max-width: 95vw; max-width: 95vw;
padding: 20px; padding: 20px;
position: relative; 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::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::after { content:''; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
.modal-header { .modal-header {
display: flex; display: flex;
@@ -600,7 +724,7 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
.form-group input, .form-group input,
.form-group select { .form-group select {
padding: 6px 9px; padding: 6px 9px;
border: 1px solid var(--border); border: 2px solid var(--border);
font-family: var(--font); font-family: var(--font);
font-size: .8em; font-size: .8em;
background: var(--bg3); background: var(--bg3);
@@ -952,9 +1076,9 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
letter-spacing: 0; letter-spacing: 0;
} }
/* SFP port (in rows — slightly narrower to suggest cage) */ /* SFP port (in rows)width overridden to 34px further down */
.switch-port-block.sfp-port { .switch-port-block.sfp-port {
width: 28px; width: 34px;
height: 38px; height: 38px;
font-size: .55em; font-size: .55em;
} }
@@ -1410,6 +1534,516 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
text-shadow: var(--glow-cyan); text-shadow: var(--glow-cyan);
} }
/* ── Topology v2 professional network diagram ──────────────────── */
/* Outer wrapper */
.topo-v2 {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
min-width: 860px;
padding: 20px 24px 24px;
position: relative;
}
/* Each tier row */
.topo-tier {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
/* ── Vertical connector section between tiers ── */
.topo-vc {
display: flex;
justify-content: center;
align-items: flex-start;
width: 100%;
position: relative;
height: 40px;
}
/* Single centered vertical wire */
.topo-vc-wire {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
width: 2px;
height: 100%;
background: linear-gradient(to bottom, var(--cyan), var(--green));
opacity: .7;
}
/* Labeled vertical connector */
.topo-vc-label {
position: absolute;
left: calc(50% + 6px);
top: 50%;
transform: translateY(-50%);
font-size: .58em;
color: var(--amber);
text-shadow: var(--glow-amber);
white-space: nowrap;
letter-spacing: .06em;
font-family: var(--font);
}
/* ── WAN tier node (Internet + Router side by side) ── */
.topo-tier-wan {
gap: 0;
flex-direction: column;
align-items: center;
}
/* ── Individual node boxes ── */
.topo-v2-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--bg3);
position: relative;
font-size: .75em;
font-family: var(--font);
min-width: 110px;
text-align: center;
transition: border-color .2s, box-shadow .2s;
}
.topo-v2-node::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.85rem; line-height:1; pointer-events:none; }
.topo-v2-node::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.85rem; line-height:1; pointer-events:none; }
.topo-v2-icon { font-size:1.3em; line-height:1; }
.topo-v2-label { font-weight:bold; letter-spacing:.04em; }
.topo-v2-sub { font-size:.58em; color:var(--text-muted); letter-spacing:.02em; }
.topo-v2-vlan { font-size:.54em; color:var(--cyan); opacity:.75; letter-spacing:.02em; }
/* Node type colours */
.topo-v2-internet { border-color:var(--cyan); color:var(--cyan); text-shadow:var(--glow-cyan); }
.topo-v2-router { border-color:var(--cyan); color:var(--cyan); text-shadow:var(--glow-cyan); }
.topo-v2-switch { border-color:var(--amber); color:var(--amber); text-shadow:var(--glow-amber); }
.topo-v2-host { border-color:var(--border); color:var(--text); cursor:default; }
/* ── CSS fork: UDM-Pro → two switches, no SVG distortion ── */
/* The fork sits between the router tier and the switch row.
Drops are at left:25% and left:75%, matching each switch's
centre (each switch lives in a 50%-wide half). */
.topo-fork {
position: relative;
width: 100%;
height: 40px;
flex-shrink: 0;
}
/* Vertical stem down from router centre */
.topo-fork-stem {
position: absolute;
left: 50%;
top: 0;
width: 2px;
height: 50%;
transform: translateX(-50%);
background: var(--amber);
opacity: .65;
}
/* Horizontal bar at mid-height, spanning between the two drop points */
.topo-fork-bar {
position: absolute;
left: 25%;
right: 25%;
top: calc(50% - 1px);
height: 2px;
background: var(--amber);
opacity: .55;
display: flex;
justify-content: center;
}
.topo-fork-label {
position: absolute;
top: -13px;
font-size: .54em;
color: var(--amber);
white-space: nowrap;
letter-spacing: .06em;
font-family: var(--font);
opacity: .85;
background: var(--bg);
padding: 0 4px;
text-shadow: var(--glow-amber);
}
/* Left and right vertical drops from bar down to switch tops */
.topo-fork-drop {
position: absolute;
top: 50%;
width: 2px;
height: 50%;
background: var(--amber);
opacity: .55;
}
.topo-fork-drop-l { left: 25%; transform: translateX(-50%); }
.topo-fork-drop-r { left: 75%; transform: translateX(-50%); }
/* ── Switch row: two equal 50% halves ── */
/* Each switch is centred in its half, so their centres are at
exactly 25% and 75% — matching the fork drops above. */
.topo-sw-row {
display: flex;
width: 100%;
position: relative;
align-items: center;
}
.topo-sw-half {
width: 50%;
display: flex;
justify-content: center;
padding: 0 16px;
position: relative;
z-index: 1; /* sit above the ISL line */
}
/* ISL line rendered as ::before — switch boxes (bg3) cover it at their edges */
.topo-sw-row::before {
content: '';
position: absolute;
left: 25%;
right: 25%;
top: 50%;
height: 2px;
background: var(--amber);
opacity: .35;
z-index: 0;
}
/* ISL label centred between the two switches */
.topo-sw-row::after {
content: '10G ISL';
position: absolute;
left: 50%;
transform: translateX(-50%);
top: calc(50% - 14px);
font-size: .5em;
color: var(--amber);
white-space: nowrap;
font-family: var(--font);
letter-spacing: .06em;
opacity: .65;
background: var(--bg);
padding: 0 5px;
z-index: 2;
}
/* ── Dual-home bus section ── */
/* This is the complex section linking two switches to N hosts */
.topo-bus-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0;
margin-top: 0;
}
/* Bus bar row: the horizontal rail that distributes to hosts */
.topo-bus-bars {
display: flex;
flex-direction: column;
align-items: stretch;
position: relative;
width: 100%;
}
/* The two drop buses: 10G (green) and 1G mgmt (amber dashed) */
.topo-bus-10g {
display: flex;
align-items: center;
position: relative;
height: 20px;
}
.topo-bus-10g-line {
flex: 1;
height: 2px;
background: var(--green);
opacity: .45;
margin: 0 4px;
}
.topo-bus-10g-label {
font-size: .56em;
color: var(--green);
text-shadow: var(--glow);
white-space: nowrap;
letter-spacing: .05em;
font-family: var(--font);
opacity: .85;
padding: 0 8px;
}
.topo-bus-1g {
display: flex;
align-items: center;
position: relative;
height: 18px;
}
.topo-bus-1g-line {
flex: 1;
height: 0;
border-top: 2px dashed var(--amber);
opacity: .35;
margin: 0 4px;
}
.topo-bus-1g-label {
font-size: .56em;
color: var(--amber);
text-shadow: var(--glow-amber);
white-space: nowrap;
letter-spacing: .05em;
font-family: var(--font);
opacity: .8;
padding: 0 8px;
}
/* ── Host row ── */
.topo-v2-hosts {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
padding-top: 4px;
width: 100%;
}
/* Host status colouring */
.topo-v2-status-up { border-color:var(--green); box-shadow:0 0 8px rgba(0,255,65,.2); }
.topo-v2-status-down { border-color:var(--red); box-shadow:0 0 8px rgba(255,68,68,.35); animation:pulse-glow 2s infinite; }
.topo-v2-status-degraded{ border-color:var(--orange); box-shadow:0 0 8px rgba(255,140,0,.2); }
.topo-v2-status-unknown { border-color:var(--border); }
/* Off-rack host: dashed border */
.topo-v2-offrack { border-style: dashed !important; }
/* ── Legend row ── */
.topo-legend {
display: flex;
gap: 18px;
align-items: center;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid rgba(0,255,65,.12);
flex-wrap: wrap;
justify-content: center;
}
.topo-legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: .58em;
color: var(--text-muted);
font-family: var(--font);
}
.topo-legend-line-10g {
width: 24px; height: 2px;
background: var(--green);
display: inline-block;
}
.topo-legend-line-1g {
width: 24px; height: 0;
border-top: 2px dashed var(--amber);
display: inline-block;
}
.topo-legend-line-isl {
width: 24px; height: 2px;
background: var(--amber);
display: inline-block;
}
.topo-legend-line-wan {
width: 24px; height: 2px;
background: linear-gradient(to right, var(--cyan), var(--green));
display: inline-block;
}
/* ── Drop-wire stubs for host dual-homing ── */
/* Wrapper that sits above each host showing its two connections */
.topo-v2-host-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.topo-v2-host-wires {
display: flex;
gap: 6px;
height: 28px;
align-items: flex-start;
}
.topo-v2-wire-10g {
width: 2px;
height: 100%;
background: var(--green);
opacity: .55;
}
.topo-v2-wire-1g {
width: 0;
height: 100%;
border-left: 2px dashed var(--amber);
opacity: .45;
}
/* host badge */
.topo-v2-badge {
font-size: .65em;
padding: 1px 5px;
border: 1px solid;
letter-spacing: .03em;
margin-top: 2px;
}
.topo-v2-badge-up { color:var(--green); border-color:var(--green); text-shadow:var(--glow); }
.topo-v2-badge-down { color:var(--red); border-color:var(--red); animation:pulse-glow 1.5s infinite; }
.topo-v2-badge-degraded{ color:var(--orange); border-color:var(--orange); }
.topo-v2-badge-unknown { color:var(--text-muted); border-color:var(--border); }
/* (removed: old SVG fork — replaced by .topo-fork CSS above) */
/* ── Drop wires from each switch down to the bus rails ── */
.topo-sw-drops {
position: relative;
width: 100%;
height: 20px;
flex-shrink: 0;
}
.topo-sw-drop {
position: absolute;
top: 0;
width: 2px;
height: 100%;
opacity: .5;
}
.topo-sw-drop-l {
left: 25%;
transform: translateX(-50%);
background: var(--green);
}
.topo-sw-drop-r {
left: 75%;
transform: translateX(-50%);
background: var(--amber);
border-left: 2px dashed var(--amber);
width: 0;
background: none;
}
/* ── Improved chassis legend ── */
.chassis-legend {
display: flex;
gap: 16px;
align-items: center;
padding: 7px 16px 8px;
border-top: 1px solid rgba(0,255,65,.1);
background: var(--bg2);
flex-wrap: wrap;
}
.chassis-legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: .58em;
color: var(--text-muted);
font-family: var(--font);
letter-spacing: .04em;
text-transform: uppercase;
}
.chassis-legend-swatch {
width: 14px;
height: 14px;
border: 1px solid;
flex-shrink: 0;
display: inline-block;
}
.cls-down { background:var(--bg3); border-color:rgba(0,255,65,.15); }
.cls-up { background:rgba(0,255,65,.06); border-color:var(--green-muted); }
.cls-poe { background:var(--amber-dim); border-color:var(--amber); }
.cls-uplink { background:var(--cyan-dim); border-color:var(--cyan); }
/* ── Port block v2: flex-col with speed sub-label ── */
.switch-port-block {
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1px;
padding: 2px 1px;
}
.port-num {
line-height: 1;
font-weight: bold;
}
.port-speed {
font-size: .72em;
opacity: .7;
line-height: 1;
font-weight: normal;
}
.port-lldp {
font-size: .62em;
opacity: .65;
line-height: 1;
max-width: 32px;
overflow: hidden;
white-space: nowrap;
text-overflow: clip;
font-weight: normal;
}
/* US24PRO port group separators (every 2 ports = 1 pair gap) */
.chassis-row.us24pro-row .switch-port-block:nth-child(2n+1):not(:first-child) {
margin-left: 6px;
}
/* SFP block: taller and narrower cage look */
.switch-port-block.sfp-block {
width: 36px;
height: 38px;
font-size: .55em;
letter-spacing: .04em;
border-left-width: 3px;
}
/* SFP port in rows — same width as copper ports so all-SFP switches
(e.g. USW-Agg / USL8A) don't appear narrower than other switches */
.switch-port-block.sfp-port {
width: 34px;
height: 40px;
font-size: .55em;
border-left-width: 2px;
}
/* Chassis mounting ears */
.chassis-body {
position: relative;
}
.chassis-ear-l,
.chassis-ear-r {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 36px;
background: var(--bg3);
border: 1px solid var(--border);
}
.chassis-ear-l { left: -9px; border-right: none; }
.chassis-ear-r { right: -9px; border-left: none; }
.chassis-ear-l::before,
.chassis-ear-r::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--border);
}
/* ── Responsive ───────────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.host-grid { grid-template-columns:1fr; } .host-grid { grid-template-columns:1fr; }
@@ -1426,13 +2060,158 @@ 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;
}
/* ── 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;
}
/* ── Pagination notice ────────────────────────────────────────────── */
.pagination-notice {
font-size: .8em;
color: var(--text-muted);
padding: 6px 0 8px;
}
.pagination-notice a {
color: var(--accent);
text-decoration: none;
}
.pagination-notice a:hover {
text-decoration: underline;
}

View File

@@ -4,9 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GANDALF{% endblock %}</title> <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') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <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"> <header class="header">
<div class="header-left"> <div class="header-left">
<div class="header-brand"> <div class="header-brand">
@@ -46,6 +50,7 @@
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}" ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}"
}; };
</script> </script>
<script src="{{ url_for('static', filename='base.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script> <script src="{{ url_for('static', filename='app.js') }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>

View File

@@ -29,56 +29,142 @@
</div> </div>
<div class="topology" id="topology-diagram"> <div class="topology" id="topology-diagram">
<div class="topo-row topo-row-internet"> <div class="topo-v2">
<div class="topo-node topo-internet">
<span class="topo-icon"></span> {%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%}
<span class="topo-label">Internet</span>
<!-- ══════════════════════════════════════════════════════════════
TIER 1: Internet (WAN edge)
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-internet">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">INTERNET</span>
<span class="topo-v2-sub">WAN uplink</span>
</div> </div>
</div> </div>
<div class="topo-connectors single">
<div class="topo-line"></div> <!-- WAN wire: cyan → green gradient, labeled -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:linear-gradient(to bottom,var(--cyan),var(--cyan)); opacity:.55;"></div>
<span class="topo-vc-label">WAN · 10G SFP+</span>
</div> </div>
<div class="topo-row">
<div class="topo-node topo-unifi" id="topo-gateway"> <!-- ══════════════════════════════════════════════════════════════
<span class="topo-icon"></span> TIER 2: Router UDM-Pro
<span class="topo-label">UDM-Pro</span> ══════════════════════════════════════════════════════════ -->
<span class="topo-status-dot" data-topo-target="gateway"></span> <div class="topo-tier">
<div class="topo-v2-node topo-v2-router">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">UDM-Pro</span>
<span class="topo-v2-sub">Dream Machine Pro</span>
<span class="topo-v2-sub">RU24</span>
</div> </div>
</div> </div>
<div class="topo-connectors single">
<div class="topo-line topo-line-labeled" data-link-label="10G DAC"></div> <!-- UDM-Pro → USW-Agg (10G SFP+) -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div>
<span class="topo-vc-label">10G SFP+</span>
</div> </div>
<div class="topo-row">
<div class="topo-node topo-switch" id="topo-switch-agg"> <!-- ══════════════════════════════════════════════════════════════
<span class="topo-icon"></span> TIER 3: USW-Aggregation
<span class="topo-label">Agg Switch</span> ══════════════════════════════════════════════════════════ -->
<span class="topo-status-dot" data-topo-target="switch-agg"></span> <div class="topo-tier">
<div class="topo-v2-node topo-v2-switch" id="topo-switch-agg">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">USW-Agg</span>
<span class="topo-v2-sub">Aggregation · RU22</span>
<span class="topo-v2-sub">8 × 10G SFP+</span>
<span class="topo-v2-vlan">VLAN90 · 10.10.90.x/24</span>
</div> </div>
</div> </div>
<div class="topo-connectors single">
<div class="topo-line topo-line-labeled" data-link-label="10G DAC"></div> <!-- USW-Agg → Pro 24 PoE (10G trunk) -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div>
<span class="topo-vc-label">10G trunk</span>
</div> </div>
<div class="topo-row">
<div class="topo-node topo-switch" id="topo-switch-poe"> <!-- ══════════════════════════════════════════════════════════════
<span class="topo-icon"></span> TIER 4: Pro 24 PoE
<span class="topo-label">PoE Switch</span> ══════════════════════════════════════════════════════════ -->
<span class="topo-status-dot" data-topo-target="switch-poe"></span> <div class="topo-tier">
<div class="topo-v2-node topo-v2-switch" id="topo-switch-poe">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">Pro 24 PoE</span>
<span class="topo-v2-sub">24-Port · RU23</span>
<span class="topo-v2-sub">24 × 1G PoE</span>
<span class="topo-v2-vlan">DHCP · mgmt</span>
</div> </div>
</div> </div>
<div class="topo-connectors wide">
{% for name in snapshot.hosts %} <!-- Pro 24 PoE → host bus section -->
<div class="topo-line"></div> <div class="topo-vc">
{% endfor %} <div class="topo-vc-wire" style="background:var(--border);opacity:.5;"></div>
</div> </div>
<div class="topo-row topo-hosts-row">
{% for name, host in snapshot.hosts.items() %} <!-- ══════════════════════════════════════════════════════════════
<div class="topo-node topo-host topo-status-{{ host.status }}" data-host="{{ name }}"> TIER 4 connecting bus two rails (10G green + 1G amber dashed)
<span class="topo-icon"></span> showing dual-homing for all 6 servers
<span class="topo-label">{{ name }}</span> ══════════════════════════════════════════════════════════ -->
<span class="topo-badge topo-badge-{{ host.status }}">{{ host.status }}</span> <div class="topo-bus-section" style="max-width:860px;">
<!-- 10G storage bus (Agg → VLAN90) -->
<div class="topo-bus-10g">
<span class="topo-bus-10g-label">← USW-Agg · 10G SFP+ · VLAN90 →</span>
<div class="topo-bus-10g-line"></div>
</div> </div>
{% endfor %}
<!-- 1G management bus (PoE → DHCP) -->
<div class="topo-bus-1g">
<span class="topo-bus-1g-label">← Pro 24 PoE · 1G · DHCP mgmt →</span>
<div class="topo-bus-1g-line"></div>
</div>
<!-- ── Host nodes with drop wires ── -->
<div class="topo-v2-hosts">
{%- set all_defs = [
('compute-storage-gpu-01', 'csg-01', 'RU412', 'Ceph · VLAN90', False),
('compute-storage-01', 'cs-01', 'RU1417', 'Ceph · VLAN90', False),
('storage-01', 'sto-01', 'rack', 'Ceph · VLAN90', False),
('monitor-01', 'mon-01', 'ZimaBoard', 'mgmt', False),
('monitor-02', 'mon-02', 'ZimaBoard', 'mgmt', False),
('large1', 'large1', 'off-rack', 'table', True),
] -%}
{%- for hname, hlabel, hsub, hvlan, off_rack in all_defs -%}
{%- set st = topo_h[hname].status if hname in topo_h else 'unknown' -%}
<div class="topo-v2-host-wrap">
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
<div class="topo-v2-host-wires">
<div class="topo-v2-wire-10g" title="10G SFP+ → USW-Agg"></div>
<div class="topo-v2-wire-1g" title="1G → Pro 24 PoE"></div>
</div>
<!-- host box -->
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}"
data-host="{{ hname }}" style="min-width:80px; max-width:96px;">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">{{ hlabel }}</span>
<span class="topo-v2-sub">{{ hsub }}</span>
<span class="topo-v2-vlan">{{ hvlan }}</span>
<span class="topo-badge topo-badge-{{ st }}">{{ st if st != 'unknown' else '' }}</span>
</div>
</div>
{%- endfor -%}
</div>
</div><!-- /topo-bus-section -->
<!-- ── Legend ── -->
<div class="topo-legend">
<div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div>
<div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div>
<div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div>
<div class="topo-legend-item" style="border:1px dashed var(--border); padding:1px 5px; font-size:.56em; color:var(--text-muted);">dashed border = off-rack</div>
</div> </div>
</div><!-- /topo-v2 -->
</div> </div>
<!-- Host cards --> <!-- Host cards -->
@@ -191,6 +277,9 @@
</div> </div>
<div id="events-table-wrap"> <div id="events-table-wrap">
{% if events %} {% if events %}
{% if total_active is defined and total_active > events|length %}
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>
{% endif %}
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="events-table"> <table class="data-table" id="events-table">
<thead> <thead>
@@ -201,6 +290,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 +305,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 +328,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>
@@ -244,6 +339,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">
@@ -299,5 +432,42 @@
{% 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);
// ── 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 %}

View File

@@ -84,16 +84,46 @@ function portBlockState(d) {
return 'up'; return 'up';
} }
// ── Speed label helper ───────────────────────────────────────────────────
function portSpeedLabel(port) {
if (!port || !port.up) return '';
const spd = port.speed; // speed in Mbps from UniFi API
if (!spd) return '';
if (spd >= 10000) return '10G';
if (spd >= 1000) return '1G';
if (spd >= 100) return '100M';
return spd + 'M';
}
// ── Render a single port block element ────────────────────────────────── // ── Render a single port block element ──────────────────────────────────
function portBlockHtml(idx, port, swName, sfpBlock) { function portBlockHtml(idx, port, swName, sfpBlock) {
const state = portBlockState(port); const state = portBlockState(port);
const label = sfpBlock ? 'SFP' : idx; const numLabel = sfpBlock ? 'SFP' : idx;
const title = port ? escHtml(port.name) : `Port ${idx}`; const title = port ? escHtml(port.name) : `Port ${idx}`;
const sfpCls = sfpBlock ? ' sfp-block' : ''; const sfpCls = sfpBlock ? ' sfp-block' : '';
const speedTxt = portSpeedLabel(port);
// LLDP neighbor: first 6 chars of hostname
const lldpName = (port && port.lldp_table && port.lldp_table.length)
? escHtml((port.lldp_table[0].chassis_id_subtype === 'local'
? port.lldp_table[0].chassis_id
: port.lldp_table[0].system_name || port.lldp_table[0].chassis_id || '').slice(0, 6))
: '';
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
return `<div class="switch-port-block ${state}${sfpCls}" return `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}" data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}" title="${title}"
onclick="selectPort(this)">${label}</div>`; onclick="selectPort(this)"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
}
// ── Chassis legend HTML ──────────────────────────────────────────────────
function chassisLegendHtml() {
return `<div class="chassis-legend">
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-down"></span>down</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-up"></span>up</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-poe"></span>poe active</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-uplink"></span>uplink</div>
</div>`;
} }
// ── Render one switch chassis ──────────────────────────────────────────── // ── Render one switch chassis ────────────────────────────────────────────
@@ -107,26 +137,38 @@ function renderChassis(swName, sw) {
const downCount = totCount - upCount; const downCount = totCount - upCount;
const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · '); const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · ');
// Is this a US24PRO? Used to add group-separator class
const isUs24Pro = (model === 'US24PRO');
let chassisHtml = ''; let chassisHtml = '';
if (layout) { if (layout) {
const sfpPortSet = new Set(layout.sfp_ports || []); const sfpPortSet = new Set(layout.sfp_ports || []);
const sfpSectionSet = new Set(layout.sfp_section || []); const sfpSectionSet = new Set(layout.sfp_section || []);
// Main port rows // Main port rows
chassisHtml += '<div class="chassis-rows">'; chassisHtml += '<div class="chassis-rows">';
for (const row of layout.rows) { for (const row of layout.rows) {
chassisHtml += '<div class="chassis-row">'; const rowCls = isUs24Pro ? ' us24pro-row' : '';
chassisHtml += `<div class="chassis-row${rowCls}">`;
for (const idx of row) { for (const idx of row) {
const port = portMap[idx]; const port = portMap[idx];
const isSfp = sfpPortSet.has(idx); const isSfp = sfpPortSet.has(idx);
const sfpCls = isSfp ? ' sfp-port' : ''; const sfpCls = isSfp ? ' sfp-port' : '';
const state = portBlockState(port); const state = portBlockState(port);
const title = port ? escHtml(port.name) : `Port ${idx}`; const title = port ? escHtml(port.name) : `Port ${idx}`;
const speedTxt = portSpeedLabel(port);
const lldpName = (port && port.lldp_table && port.lldp_table.length)
? escHtml((port.lldp_table[0].chassis_id_subtype === 'local'
? port.lldp_table[0].chassis_id
: port.lldp_table[0].system_name || port.lldp_table[0].chassis_id || '').slice(0, 6))
: '';
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}" chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}" data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}" title="${title}"
onclick="selectPort(this)">${idx}</div>`; onclick="selectPort(this)"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
} }
chassisHtml += '</div>'; chassisHtml += '</div>';
} }
@@ -158,7 +200,12 @@ function renderChassis(swName, sw) {
${sw.ip ? `<span class="chassis-ip">${escHtml(sw.ip)}</span>` : ''} ${sw.ip ? `<span class="chassis-ip">${escHtml(sw.ip)}</span>` : ''}
<span class="chassis-meta">${escHtml(meta)}</span> <span class="chassis-meta">${escHtml(meta)}</span>
</div> </div>
<div class="chassis-body">${chassisHtml}</div> <div class="chassis-body">
<div class="chassis-ear-l"></div>
<div class="chassis-ear-r"></div>
${chassisHtml}
</div>
${chassisLegendHtml()}
</div>`; </div>`;
} }
@@ -326,8 +373,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 +411,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,81 @@ 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', '{}'); }
// ── 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 ──────────────────────────────────────────────
@@ -463,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>
@@ -482,16 +586,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>`;
} }
} }