feat: inspector page, link debug enhancements, security hardening

- Add /inspector page: visual model-accurate switch chassis diagrams
  (USF5P, USL8A, US24PRO, USPPDUP, USMINI), clickable port blocks
  with color coding (green=up, amber=PoE, cyan=uplink, grey=down),
  detail panel with stats/PoE/LLDP, LLDP-based path debug side-by-side

- Link Debug: port number badges (#N), LLDP neighbor line, PoE class/max,
  collapsible host/switch panels with sessionStorage persistence

- monitor.py: collect LLDP neighbor map + PoE class/max/mode per switch
  port; PulseClient uses requests.Session() for HTTP keep-alive; add
  shlex.quote() around interface names (defense-in-depth)

- Security: suppress buttons use data-* attrs + delegated click handler
  instead of inline onclick with Jinja2 variable interpolation; remove
  | safe filter from user-controlled fields in suppressions.html;
  setDuration() takes explicit el param instead of implicit event global

- db.py: thread-local connection reuse with ping(reconnect=True) to
  avoid a new TCP handshake per query

- .gitignore: add config.json (contains credentials), __pycache__

- README: full rewrite covering architecture, all 4 pages, alert logic,
  config reference, deployment, troubleshooting, security notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 15:39:48 -05:00
parent fa7512a2c2
commit 0278dad502
12 changed files with 1548 additions and 176 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
log.txt
config.json
__pycache__/
*.pyc

429
README.md
View File

@@ -1,6 +1,6 @@
# GANDALF (Global Advanced Network Detection And Link Facilitator)
> Because it shall not let problems pass!
> Because it shall not let problems pass.
Network monitoring dashboard for the LotusGuild Proxmox cluster.
Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`.
@@ -9,7 +9,7 @@ Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusgu
## Architecture
Gandalf is two processes that share a MariaDB database:
Two processes share a MariaDB database:
| Process | Service | Role |
|---|---|---|
@@ -18,21 +18,23 @@ Gandalf is two processes that share a MariaDB database:
```
[Prometheus :9090] ──▶
monitor.py ──▶ MariaDB ◀── app.py ──▶ nginx ──▶ Authelia ──▶ Browser
[UniFi Controller] ──▶
[UniFi Controller] ──▶ monitor.py ──▶ MariaDB ◀── app.py ──▶ nginx ──▶ Authelia ──▶ Browser
[Pulse Worker] ──▶
[SSH / ethtool] ──▶
```
### Data Sources
| Source | What it monitors |
| Source | What it provides |
|---|---|
| **Prometheus** (`10.10.10.48:9090`) | Physical NIC link state (`node_network_up`) for 6 Proxmox hosts |
| **UniFi API** (`https://10.10.10.1`) | Switch, AP, and gateway device status |
| **Ping** | pbs (10.10.10.3) — no node_exporter |
| **Prometheus** (`10.10.10.48:9090`) | Physical NIC link state + traffic/error rates via `node_exporter` |
| **UniFi API** (`https://10.10.10.1`) | Switch port stats, device status, LLDP neighbor table, PoE data |
| **Pulse Worker** | SSH relay — runs `ethtool` + SFP DOM queries on each Proxmox host |
| **Ping** | Reachability for hosts without `node_exporter` (e.g. PBS) |
### Monitored Hosts (Prometheus / node_exporter)
| Host | Instance |
| Host | Prometheus Instance |
|---|---|
| large1 | 10.10.10.2:9100 |
| compute-storage-01 | 10.10.10.4:9100 |
@@ -41,18 +43,86 @@ Gandalf is two processes that share a MariaDB database:
| compute-storage-gpu-01 | 10.10.10.10:9100 |
| storage-01 | 10.10.10.11:9100 |
Ping-only (no node_exporter): **pbs** (10.10.10.3)
---
## Features
## Pages
- **Interface monitoring** tracks link state for all physical NICs via Prometheus
- **UniFi device monitoring** detects offline switches, APs, and gateways
- **Ping reachability** covers hosts without node_exporter
- **Cluster-wide detection** creates a separate P1 ticket when 3+ hosts have simultaneous interface failures (likely a switch failure)
- **Smart baseline tracking** interfaces that are down on first observation (unused ports) are never alerted on; only regressions from UP→DOWN trigger tickets
- **Ticket creation** integrates with Tinker Tickets (`t.lotusguild.org`) with 24-hour deduplication
- **Alert suppression** manual toggle or timed windows (30min / 1hr / 4hr / 8hr / manual)
- **Authelia SSO** restricted to `admin` group via forward-auth headers
### Dashboard (`/`)
- Real-time host status grid with per-NIC link state (UP / DOWN / degraded)
- Network topology diagram (Internet → Gateway → Switches → Hosts)
- UniFi device table (switches, APs, gateway)
- Active alerts table with severity, target, consecutive failures, ticket link
- Quick-suppress modal: apply timed or manual suppression from any alert row
- Auto-refreshes every 30 seconds via `/api/status` + `/api/network`
### Link Debug (`/links`)
Per-interface statistics collected every poll cycle. All panels are collapsible
(click header or use Collapse All / Expand All). Collapse state persists across
page refreshes via `sessionStorage`.
**Server NICs** (via Prometheus + SSH/ethtool):
- Speed, duplex, auto-negotiation, link detected
- TX/RX rate bars (bandwidth utilisation % of link capacity)
- TX/RX error and drop rates per second
- Carrier changes (cumulative since boot — watch for flapping)
- **SFP / Optical panel** (when SFP module present): vendor/PN, temp, voltage,
bias current, TX power (dBm), RX power (dBm), RXTX delta, per-stat bars
**UniFi Switch Ports** (via UniFi API):
- Port number badge (`#N`), UPLINK badge, PoE draw badge
- LLDP neighbor line: `→ system_name (port_id)` when neighbor is detected
- PoE class and max wattage line
- Speed, duplex, auto-neg, TX/RX rates, errors, drops
### Inspector (`/inspector`)
Visual switch chassis diagrams. Each switch is rendered model-accurately using
layout config in the template (`SWITCH_LAYOUTS`).
**Port block colours:**
| Colour | State |
|---|---|
| Green | Up, no active PoE |
| Amber | Up with active PoE draw |
| Cyan | Uplink port (up) |
| Grey | Down |
| White outline | Currently selected |
**Clicking a port** opens the right-side detail panel showing:
- Link stats (status, speed, duplex, auto-neg, media type)
- PoE (class, max wattage, current draw, mode)
- Traffic (TX/RX rates)
- Errors/drops per second
- **LLDP Neighbor** section (system name, port ID, chassis ID, management IPs)
- **Path Debug** (auto-appears when LLDP `system_name` matches a known server):
two-column comparison of the switch port stats vs. the server NIC stats,
including SFP DOM data if the server side has an SFP module
**LLDP path debug requirements:**
1. Server must run `lldpd`: `apt install lldpd && systemctl enable --now lldpd`
2. `lldpd` hostname must match the key in `data.hosts` (set via `config.json → hosts`)
3. Switch has LLDP enabled (UniFi default: on)
**Supported switch models** (set `SWITCH_LAYOUTS` keys to your UniFi model codes):
| Key | Model | Layout |
|---|---|---|
| `USF5P` | UniFi Switch Flex 5 PoE | 4×RJ45 + 1×SFP uplink |
| `USL8A` | UniFi Switch Lite 8 PoE | 8×SFP (2 rows of 4) |
| `US24PRO` | UniFi Switch Pro 24 | 24×RJ45 staggered + 2×SFP |
| `USPPDUP` | Custom/other | Single-port fallback |
| `USMINI` | UniFi Switch Mini | 5-port row |
Add new layouts by adding a key to `SWITCH_LAYOUTS` matching the `model` field
returned by the UniFi API for that device.
### Suppressions (`/suppressions`)
- Create timed (30 min / 1 hr / 4 hr / 8 hr) or manual suppressions
- Target types: host, interface, UniFi device, or global
- Active suppressions table with one-click removal
- Suppression history (last 50)
- Available targets reference grid (all known hosts + interfaces)
---
@@ -62,10 +132,16 @@ Gandalf is two processes that share a MariaDB database:
| Condition | Priority |
|---|---|
| UniFi device offline (2+ consecutive checks) | P2 High |
| Proxmox host NIC link-down regression (2+ consecutive checks) | P2 High |
| Host unreachable via ping (2+ consecutive checks) | P2 High |
| 3+ hosts simultaneously reporting interface failures | P1 Critical |
| UniFi device offline (2 consecutive checks) | P2 High |
| Proxmox host NIC link-down regression (2 consecutive checks) | P2 High |
| Host unreachable via ping (2 consecutive checks) | P2 High |
| 3 hosts simultaneously reporting interface failures | P1 Critical |
### Baseline Tracking
Interfaces that are **down on first observation** (unused ports, unplugged cables)
are recorded as `initial_down` and never alerted. Only **UP→DOWN regressions**
generate tickets. Baseline is stored in MariaDB and survives daemon restarts.
### Suppression Targets
@@ -77,30 +153,88 @@ Gandalf is two processes that share a MariaDB database:
| `all` | Everything (global maintenance mode) |
Suppressions can be manual (persist until removed) or timed (auto-expire).
Expired suppressions are checked at evaluation time — no background cleanup needed.
---
## Configuration
## Configuration (`config.json`)
**`config.json`** shared by both processes:
Shared by both processes. Located in the working directory (`/var/www/html/prod/`).
```json
{
"database": {
"host": "10.10.10.50",
"port": 3306,
"user": "gandalf",
"password": "...",
"name": "gandalf"
},
"prometheus": {
"url": "http://10.10.10.48:9090"
},
"unifi": {
"controller": "https://10.10.10.1",
"api_key": "...",
"site_id": "default"
},
"ticket_api": {
"url": "https://t.lotusguild.org/api/tickets",
"api_key": "..."
},
"pulse": {
"url": "http://<pulse-host>:<port>",
"api_key": "...",
"worker_id": "...",
"timeout": 45
},
"auth": {
"allowed_groups": ["admin"]
},
"hosts": [
{ "name": "large1", "prometheus_instance": "10.10.10.2:9100" },
{ "name": "compute-storage-01", "prometheus_instance": "10.10.10.4:9100" },
{ "name": "micro1", "prometheus_instance": "10.10.10.8:9100" },
{ "name": "monitor-02", "prometheus_instance": "10.10.10.9:9100" },
{ "name": "compute-storage-gpu-01", "prometheus_instance": "10.10.10.10:9100" },
{ "name": "storage-01", "prometheus_instance": "10.10.10.11:9100" }
],
"monitor": {
"poll_interval": 120,
"failure_threshold": 2,
"cluster_threshold": 3,
"ping_hosts": [
{ "name": "pbs", "ip": "10.10.10.3" }
]
}
}
```
### Key Config Fields
| Key | Description |
|---|---|
| `unifi.api_key` | UniFi API key from controller |
| `database.*` | MariaDB credentials (LXC 149 at 10.10.10.50) |
| `prometheus.url` | Prometheus base URL |
| `database.*` | MariaDB credentials |
| `ticket_api.api_key` | Tinker Tickets Bearer token |
| `monitor.poll_interval` | Seconds between checks (default: 120) |
| `monitor.failure_threshold` | Consecutive failures before ticketing (default: 2) |
| `monitor.cluster_threshold` | Hosts with failures to trigger cluster alert (default: 3) |
| `monitor.ping_hosts` | Hosts checked via ping (no node_exporter) |
| `hosts` | Maps Prometheus instance labels to hostnames |
| `unifi.controller` | UniFi controller base URL (HTTPS, self-signed cert ignored) |
| `unifi.api_key` | UniFi API key from controller Settings → API |
| `unifi.site_id` | UniFi site ID (default: `default`) |
| `ticket_api.api_key` | Tinker Tickets bearer token |
| `pulse.url` | Pulse worker API base URL (for SSH relay) |
| `pulse.worker_id` | Which Pulse worker runs ethtool collection |
| `pulse.timeout` | Max seconds to wait for SSH collection per host |
| `auth.allowed_groups` | Authelia groups that may access Gandalf |
| `hosts` | Maps Prometheus instance labels → display hostnames |
| `monitor.poll_interval` | Seconds between full check cycles (default: 120) |
| `monitor.failure_threshold` | Consecutive failures before creating ticket (default: 2) |
| `monitor.cluster_threshold` | Hosts with failures to trigger cluster-wide P1 (default: 3) |
| `monitor.ping_hosts` | Hosts checked only by ping (no node_exporter) |
---
## Deployment (LXC 157)
### 1. Database (MariaDB LXC 149 at 10.10.10.50)
### 1. Database MariaDB LXC 149 (`10.10.10.50`)
```sql
CREATE DATABASE gandalf CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -109,93 +243,234 @@ GRANT ALL PRIVILEGES ON gandalf.* TO 'gandalf'@'10.10.10.61';
FLUSH PRIVILEGES;
```
Then import the schema:
Import schema:
```bash
mysql -h 10.10.10.50 -u gandalf -p gandalf < schema.sql
```
### 2. LXC 157 Install dependencies
### 2. LXC 157 Install dependencies
```bash
pip3 install -r requirements.txt
# Ensure sshpass is available (used by deploy scripts)
apt install sshpass
```
### 3. Deploy files
```bash
cp app.py db.py monitor.py config.json templates/ static/ /var/www/html/prod/
# From dev machine / root/code/gandalf:
for f in app.py db.py monitor.py config.json schema.sql \
static/style.css static/app.js \
templates/*.html; do
sshpass -p 'yourpass' scp -o StrictHostKeyChecking=no \
"$f" "root@10.10.10.61:/var/www/html/prod/$f"
done
systemctl restart gandalf gandalf-monitor
```
### 4. Configure secrets in `config.json`
### 4. systemd services
- `database.password` set the gandalf DB password
- `ticket_api.api_key` copy from tinker tickets admin panel
**`gandalf.service`** (Flask/gunicorn web app):
```ini
[Unit]
Description=Gandalf Web Dashboard
After=network.target
### 5. Install the monitor service
```bash
cp gandalf-monitor.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable gandalf-monitor
systemctl start gandalf-monitor
```
Update existing `gandalf.service` to use a single worker:
```
[Service]
Type=simple
WorkingDirectory=/var/www/html/prod
ExecStart=/usr/bin/python3 -m gunicorn --workers 1 --bind 127.0.0.1:8000 app:app
Restart=always
[Install]
WantedBy=multi-user.target
```
### 6. Authelia rule
**`gandalf-monitor.service`** (background polling daemon):
```ini
[Unit]
Description=Gandalf Network Monitor Daemon
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/www/html/prod
ExecStart=/usr/bin/python3 monitor.py
Restart=always
[Install]
WantedBy=multi-user.target
```
### 5. Authelia rule (LXC 167)
Add to `/etc/authelia/configuration.yml` access_control rules:
```yaml
access_control:
rules:
- domain: gandalf.lotusguild.org
policy: one_factor
subject:
- group:admin
```
Reload Authelia: `systemctl restart authelia`
```bash
systemctl restart authelia
```
### 7. NPM proxy host
### 6. NPM reverse proxy
- Domain: `gandalf.lotusguild.org`
- Forward to: `http://10.10.10.61:80` (nginx on LXC 157)
- Enable Authelia forward auth
- WebSockets: **not required**
- **Domain:** `gandalf.lotusguild.org`
- **Forward to:** `http://10.10.10.61:8000` (gunicorn direct, no nginx needed on LXC)
- **Forward Auth:** Authelia at `http://10.10.10.167:9091`
- **WebSockets:** Not required
---
## Service Management
```bash
# Monitor daemon
systemctl status gandalf-monitor
# Status
systemctl status gandalf gandalf-monitor
# Logs (live)
journalctl -u gandalf -f
journalctl -u gandalf-monitor -f
# Web server
systemctl status gandalf
journalctl -u gandalf -f
# Restart both after config/code changes
systemctl restart gandalf-monitor gandalf
# Restart after code or config changes
systemctl restart gandalf gandalf-monitor
```
---
## Troubleshooting
**Monitor not creating tickets**
- Check `config.json``ticket_api.api_key` is set
- Check `journalctl -u gandalf-monitor` for errors
### Monitor not creating tickets
- Verify `config.json → ticket_api.api_key` is set and valid
- Check `journalctl -u gandalf-monitor` for `Ticket creation failed` lines
- Confirm the Tinker Tickets API is reachable from LXC 157
**Baseline re-initializing on every restart**
- `interface_baseline` is stored in the `monitor_state` DB table; it persists across restarts
### Link Debug shows no data / "Loading…" forever
- Check `gandalf-monitor.service` is running and has completed at least one cycle
- Check `journalctl -u gandalf-monitor` for Prometheus or UniFi errors
- Verify Prometheus is reachable: `curl http://10.10.10.48:9090/api/v1/query?query=up`
**Interface always showing as "initial_down"**
- That interface was down on the first poll after the monitor started
- It will begin tracking once it comes up; or manually update the baseline in DB if needed
### Link Debug: SFP DOM panel missing
- SFP data requires Pulse worker + SSH access to hosts
- Verify `config.json → pulse.*` is configured and the Pulse worker is running
- Confirm `sshpass` + SSH access from the Pulse worker to each Proxmox host
- Only interfaces with physical SFP modules return DOM data (`ethtool -m`)
**Prometheus data missing for a host**
- Verify node_exporter is running: `systemctl status prometheus-node-exporter`
- Check Prometheus targets: `http://10.10.10.48:9090/targets`
### Inspector: path debug section not appearing
- Requires LLDP: run `apt install lldpd && systemctl enable --now lldpd` on each server
- The LLDP `system_name` broadcast by `lldpd` must match the hostname in `config.json → hosts[].name`
- Override: `echo 'configure system hostname large1' > /etc/lldpd.d/hostname.conf && systemctl restart lldpd`
- Allow up to 2 poll cycles (240s) after installing lldpd for LLDP table to populate
### Inspector: switch chassis shows as flat list (no layout)
- The switch's `model` field from UniFi doesn't match any key in `SWITCH_LAYOUTS` in `inspector.html`
- Check the UniFi API: the model appears in the `link_stats` API response under `unifi_switches.<name>.model`
- Add the model key to `SWITCH_LAYOUTS` in `inspector.html` with the correct row/SFP layout
### Baseline re-initializing on every restart
- `interface_baseline` is stored in the `monitor_state` DB table; survives restarts
- If it appears to reset: check DB connectivity from the monitor daemon
### Interface stuck at "initial_down" forever
- This means the interface was down when the monitor first saw it
- It will begin tracking once it comes up; or manually clear it:
```sql
-- In MariaDB on 10.10.10.50:
UPDATE monitor_state SET value='{}' WHERE key_name='interface_baseline';
```
Then restart the monitor: `systemctl restart gandalf-monitor`
### Prometheus data missing for a host
```bash
# On the affected host:
systemctl status prometheus-node-exporter
# Verify it's scraped:
curl http://10.10.10.48:9090/api/v1/query?query=up | jq '.data.result[] | select(.metric.job=="node")'
```
---
## Development Notes
### File Layout
```
gandalf/
├── app.py # Flask web app (routes, auth, API endpoints)
├── monitor.py # Background daemon (Prometheus, UniFi, Pulse, alert logic)
├── db.py # Database operations (MariaDB via pymysql, thread-local conn reuse)
├── schema.sql # Database schema (network_events, suppression_rules, monitor_state)
├── config.json # Runtime configuration (not committed with secrets)
├── requirements.txt # Python dependencies
├── static/
│ ├── style.css # Terminal aesthetic CSS (CRT scanlines, green-on-black)
│ └── app.js # Dashboard JS (auto-refresh, host grid, events, suppress modal)
└── templates/
├── base.html # Shared layout (header, nav, footer)
├── index.html # Dashboard page
├── links.html # Link Debug page (server NICs + UniFi switch ports)
├── inspector.html # Visual switch inspector + LLDP path debug
└── suppressions.html # Suppression management page
```
### Adding a New Monitored Host
1. Install `prometheus-node-exporter` on the host
2. Add a scrape target to Prometheus config
3. Add an entry to `config.json → hosts`:
```json
{ "name": "newhost", "prometheus_instance": "10.10.10.X:9100" }
```
4. Restart monitor: `systemctl restart gandalf-monitor`
5. For SFP DOM / ethtool: ensure the host is SSH-accessible from the Pulse worker
### Adding a New Switch Layout (Inspector)
Find the UniFi model code for the switch (it appears in the `/api/links` JSON response
under `unifi_switches.<switch_name>.model`), then add to `SWITCH_LAYOUTS` in
`templates/inspector.html`:
```javascript
'MYNEWMODEL': {
rows: [[1,2,3,4,5,6,7,8], [9,10,11,12,13,14,15,16]], // port_idx by row
sfp_section: [17, 18], // separate SFP cage ports (rendered below rows)
sfp_ports: [], // port_idx values that are SFP-type within rows
},
```
### Database Schema Notes
- `network_events`: one row per active event; `resolved_at` is set when recovered
- `suppression_rules`: `active=FALSE` when removed; `expires_at` checked at query time
- `monitor_state`: key/value store; `interface_baseline` and `link_stats` are JSON blobs
### Security Notes
- **XSS prevention**: all user-controlled data in dynamically generated HTML uses
`escHtml()` (JS) or Jinja2 auto-escaping (Python). Suppress buttons use `data-*`
attributes + a single delegated click listener rather than inline `onclick` with
interpolated strings.
- **Interface name validation**: `monitor.py` validates SSH interface names against
`^[a-zA-Z0-9_.@-]+$` before use, and additionally wraps them with `shlex.quote()`
for defense-in-depth.
- **DB parameters**: all SQL uses parameterised queries via pymysql — no string
concatenation into SQL.
- **Auth**: Authelia enforces admin-only access at the nginx/LXC 167 layer; the Flask
app additionally checks the `Remote-User` header via `@require_auth`.
### Known Limitations
- Single gunicorn worker (`--workers 1`) — required because `db.py` uses thread-local
connection reuse (one connection per thread). Multiple workers would each have their
own connection, which is fine, but the thread-local optimisation only helps within
one worker.
- No CSRF tokens on API endpoints — mitigated by Authelia session cookies being
`SameSite=Strict` and the site being admin-only.
- SSH collection via Pulse is synchronous — if Pulse is slow, the entire monitor cycle
is delayed. The `pulse.timeout` config controls the max wait.
- UniFi LLDP data is only as fresh as the last monitor poll (120s default).

7
app.py
View File

@@ -103,6 +103,13 @@ def links_page():
return render_template('links.html', user=user)
@app.route('/inspector')
@require_auth
def inspector():
user = _get_user()
return render_template('inspector.html', user=user)
@app.route('/suppressions')
@require_auth
def suppressions_page():

11
db.py
View File

@@ -1,6 +1,7 @@
"""Database operations for Gandalf network monitor."""
import json
import logging
import threading
from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Optional
@@ -11,6 +12,7 @@ import pymysql.cursors
logger = logging.getLogger(__name__)
_config_cache = None
_local = threading.local()
def _config() -> dict:
@@ -23,7 +25,10 @@ def _config() -> dict:
@contextmanager
def get_conn():
"""Yield a per-thread cached database connection, reconnecting as needed."""
cfg = _config()
conn = getattr(_local, 'conn', None)
if conn is None:
conn = pymysql.connect(
host=cfg['host'],
port=cfg.get('port', 3306),
@@ -35,10 +40,10 @@ def get_conn():
connect_timeout=10,
charset='utf8mb4',
)
try:
_local.conn = conn
else:
conn.ping(reconnect=True)
yield conn
finally:
conn.close()
# ---------------------------------------------------------------------------

View File

@@ -10,6 +10,7 @@ Run as a separate systemd service alongside the Flask web app.
import json
import logging
import re
import shlex
import subprocess
import time
from datetime import datetime
@@ -120,6 +121,70 @@ class UnifiClient:
logger.error(f'UniFi API error: {e}')
return None
def get_switch_ports(self) -> Optional[Dict[str, dict]]:
"""Return per-port stats for all UniFi switches, keyed by switch name.
Uses the v1 stat API which includes full port_table data.
Returns {switch_name: {'ip': str, 'model': str, 'ports': {port_name: {...}}}}.
"""
try:
url = f'{self.base_url}/proxy/network/api/s/{self.site_id}/stat/device'
resp = self.session.get(url, headers=self.headers, timeout=15)
resp.raise_for_status()
devices = resp.json().get('data', [])
result: Dict[str, dict] = {}
for dev in devices:
if dev.get('type', '').lower() != 'usw':
continue
sw_name = dev.get('name') or dev.get('mac', 'unknown')
sw_ip = dev.get('ip', '')
sw_model = dev.get('model', '')
ports: Dict[str, dict] = {}
# Build LLDP neighbor map (keyed by port_idx)
lldp_map: Dict[int, dict] = {}
for entry in dev.get('lldp_table', []):
pidx = entry.get('lldp_port_idx')
if pidx is not None:
lldp_map[int(pidx)] = {
'chassis_id': entry.get('chassis_id', ''),
'system_name': entry.get('system_name', ''),
'port_id': entry.get('port_id', ''),
'port_desc': entry.get('port_desc', ''),
'mgmt_ips': entry.get('management_ips', []),
}
for port in dev.get('port_table', []):
idx = port.get('port_idx', 0)
pname = port.get('name') or f'Port {idx}'
raw_poe = port.get('poe_power')
raw_poe_max = port.get('poe_max_power')
ports[pname] = {
'port_idx': idx,
'switch_ip': sw_ip,
'up': port.get('up', False),
'speed_mbps': port.get('speed', 0),
'full_duplex': port.get('full_duplex', False),
'autoneg': port.get('autoneg', False),
'is_uplink': port.get('is_uplink', False),
'media': port.get('media', ''),
'poe_power': float(raw_poe) if raw_poe is not None else None,
'poe_class': port.get('poe_class'),
'poe_max_power': float(raw_poe_max) if raw_poe_max is not None else None,
'poe_mode': port.get('poe_mode', ''),
'lldp': lldp_map.get(idx),
'tx_bytes': port.get('tx_bytes', 0),
'rx_bytes': port.get('rx_bytes', 0),
'tx_errors': port.get('tx_errors', 0),
'rx_errors': port.get('rx_errors', 0),
'tx_dropped': port.get('tx_dropped', 0),
'rx_dropped': port.get('rx_dropped', 0),
}
if ports:
result[sw_name] = {'ip': sw_ip, 'model': sw_model, 'ports': ports}
return result
except Exception as e:
logger.error(f'UniFi switch port stats error: {e}')
return None
# --------------------------------------------------------------------------
# Ticket client
@@ -162,29 +227,90 @@ class TicketClient:
return None
# --------------------------------------------------------------------------
# Pulse HTTP client (delegates SSH commands to Pulse worker)
# --------------------------------------------------------------------------
class PulseClient:
"""Submit a command to a Pulse worker via the internal M2M API and poll for result."""
def __init__(self, cfg: dict):
p = cfg.get('pulse', {})
self.url = p.get('url', '').rstrip('/')
self.api_key = p.get('api_key', '')
self.worker_id = p.get('worker_id', '')
self.timeout = p.get('timeout', 45)
self.session = requests.Session()
self.session.headers.update({
'X-Gandalf-API-Key': self.api_key,
'Content-Type': 'application/json',
})
def run_command(self, command: str) -> Optional[str]:
"""Submit *command* to Pulse, poll until done, return stdout or None."""
if not self.url or not self.api_key or not self.worker_id:
return None
try:
resp = self.session.post(
f'{self.url}/api/internal/command',
json={'worker_id': self.worker_id, 'command': command},
timeout=10,
)
resp.raise_for_status()
execution_id = resp.json()['execution_id']
except Exception as e:
logger.debug(f'Pulse command submit failed: {e}')
return None
deadline = time.time() + self.timeout
while time.time() < deadline:
time.sleep(1)
try:
r = self.session.get(
f'{self.url}/api/internal/executions/{execution_id}',
timeout=10,
)
r.raise_for_status()
data = r.json()
status = data.get('status')
if status == 'completed':
logs = data.get('logs', [])
for entry in logs:
if entry.get('action') == 'command_result':
return entry.get('stdout', '')
return ''
if status == 'failed':
return None
except Exception as e:
logger.debug(f'Pulse poll failed: {e}')
logger.warning(f'Pulse command timed out after {self.timeout}s')
return None
# --------------------------------------------------------------------------
# Link stats collector (ethtool + Prometheus traffic metrics)
# --------------------------------------------------------------------------
class LinkStatsCollector:
"""Collects detailed per-interface statistics via SSH (ethtool) and Prometheus."""
"""Collects detailed per-interface statistics via SSH (ethtool) and Prometheus,
plus per-port stats from UniFi switches."""
def __init__(self, cfg: dict, prom: 'PrometheusClient'):
def __init__(self, cfg: dict, prom: 'PrometheusClient',
unifi: Optional['UnifiClient'] = None):
self.prom = prom
ssh = cfg.get('ssh', {})
self.ssh_user = ssh.get('user', 'root')
self.ssh_pass = ssh.get('password', '')
self.ssh_connect_timeout = ssh.get('connect_timeout', 5)
self.ssh_timeout = ssh.get('timeout', 20)
self.pulse = PulseClient(cfg)
self.unifi = unifi
# State for UniFi rate calculation (previous snapshot + timestamp)
self._prev_unifi: Dict[str, dict] = {}
self._prev_unifi_time: float = 0.0
# ------------------------------------------------------------------
# SSH collection
# SSH collection (via Pulse worker)
# ------------------------------------------------------------------
def _ssh_batch(self, ip: str, ifaces: List[str]) -> Dict[str, dict]:
"""
Open one SSH session to *ip* and collect ethtool + SFP DOM data for
all *ifaces*. Returns {iface: {speed_mbps, duplex, ..., sfp: {...}}}.
Delegate one SSH session to the Pulse worker to collect ethtool + SFP DOM
data for all *ifaces*. Returns {iface: {speed_mbps, duplex, ..., sfp: {...}}}.
"""
if not ifaces or not self.ssh_pass:
if not ifaces or not self.pulse.url:
return {}
# Validate interface names (kernel names only contain [a-zA-Z0-9_.-])
@@ -195,37 +321,23 @@ class LinkStatsCollector:
# Build a single shell command: for each iface output ethtool + -m with sentinels
parts = []
for iface in safe_ifaces:
q = shlex.quote(iface)
parts.append(
f'echo "___IFACE:{iface}___";'
f' ethtool "{iface}" 2>/dev/null;'
f' ethtool {q} 2>/dev/null;'
f' echo "___DOM:{iface}___";'
f' ethtool -m "{iface}" 2>/dev/null;'
f' ethtool -m {q} 2>/dev/null;'
f' echo "___END___"'
)
shell_cmd = ' '.join(parts)
try:
result = subprocess.run(
[
'sshpass', '-p', self.ssh_pass,
'ssh',
'-o', 'StrictHostKeyChecking=no',
'-o', f'ConnectTimeout={self.ssh_connect_timeout}',
'-o', 'LogLevel=ERROR',
'-o', 'BatchMode=no',
f'{self.ssh_user}@{ip}',
shell_cmd,
],
capture_output=True,
text=True,
timeout=self.ssh_timeout,
ssh_cmd = (
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
f'-o LogLevel=ERROR root@{ip} "{shell_cmd}"'
)
output = result.stdout
except FileNotFoundError:
logger.debug('sshpass not found skipping ethtool collection')
return {}
except Exception as e:
logger.debug(f'SSH ethtool {ip}: {e}')
output = self.pulse.run_command(ssh_cmd)
if output is None:
logger.debug(f'Pulse ethtool collection returned None for {ip}')
return {}
return self._parse_ssh_output(output)
@@ -415,9 +527,9 @@ class LinkStatsCollector:
host_ip = instance.split(':')[0]
ifaces = list(iface_metrics.keys())
# SSH ethtool collection (one connection per host, all ifaces)
# SSH ethtool collection via Pulse worker (one connection per host, all ifaces)
ethtool_data: Dict[str, dict] = {}
if self.ssh_pass and ifaces:
if self.pulse.url and ifaces:
try:
ethtool_data = self._ssh_batch(host_ip, ifaces)
except Exception as e:
@@ -438,11 +550,52 @@ class LinkStatsCollector:
result_hosts[host] = merged
# Collect UniFi switch port stats
unifi_switches: dict = {}
if self.unifi:
try:
raw = self.unifi.get_switch_ports()
if raw is not None:
now = time.time()
unifi_switches = self._compute_unifi_rates(raw, now)
self._prev_unifi = raw
self._prev_unifi_time = now
except Exception as e:
logger.warning(f'UniFi switch port collection failed: {e}')
return {
'hosts': result_hosts,
'unifi_switches': unifi_switches,
'updated': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'),
}
def _compute_unifi_rates(self, raw: Dict[str, dict], now: float) -> Dict[str, dict]:
"""Compute per-port byte/error rates from delta against previous snapshot."""
dt = now - self._prev_unifi_time if self._prev_unifi_time > 0 else 0
def rate(new_val: int, old_val: int) -> Optional[float]:
if dt <= 0:
return None
return max(0.0, (new_val - old_val) / dt)
result: Dict[str, dict] = {}
for sw_name, sw_data in raw.items():
prev_ports = self._prev_unifi.get(sw_name, {}).get('ports', {})
merged_ports: Dict[str, dict] = {}
for pname, d in sw_data['ports'].items():
entry = dict(d)
prev = prev_ports.get(pname, {})
entry['tx_bytes_rate'] = rate(d['tx_bytes'], prev.get('tx_bytes', 0))
entry['rx_bytes_rate'] = rate(d['rx_bytes'], prev.get('rx_bytes', 0))
entry['tx_errs_rate'] = rate(d['tx_errors'], prev.get('tx_errors', 0))
entry['rx_errs_rate'] = rate(d['rx_errors'], prev.get('rx_errors', 0))
entry['tx_drops_rate'] = rate(d['tx_dropped'], prev.get('tx_dropped', 0))
entry['rx_drops_rate'] = rate(d['rx_dropped'], prev.get('rx_dropped', 0))
merged_ports[pname] = entry
result[sw_name] = {'ip': sw_data['ip'], 'model': sw_data['model'],
'ports': merged_ports}
return result
# --------------------------------------------------------------------------
# Helpers
@@ -479,7 +632,7 @@ class NetworkMonitor:
self.prom = PrometheusClient(prom_url)
self.unifi = UnifiClient(self.cfg['unifi'])
self.tickets = TicketClient(self.cfg.get('ticket_api', {}))
self.link_stats = LinkStatsCollector(self.cfg, self.prom)
self.link_stats = LinkStatsCollector(self.cfg, self.prom, self.unifi)
mon = self.cfg.get('monitor', {})
self.poll_interval = mon.get('poll_interval', 120)

View File

@@ -105,7 +105,9 @@ function updateUnifiTable(devices) {
const statusText = d.connected ? 'Online' : 'Offline';
const suppressBtn = !d.connected
? `<button class="btn-sm btn-suppress"
onclick="openSuppressModal('unifi_device','${escHtml(d.name)}','')">🔕 Suppress</button>`
data-sup-type="unifi_device"
data-sup-name="${escHtml(d.name)}"
data-sup-detail="">🔕 Suppress</button>`
: '';
return `
<tr class="${statusClass}">
@@ -149,9 +151,9 @@ function updateEventsTable(events) {
<td>${ticket}</td>
<td>
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('${supType}','${escHtml(e.target_name)}','${escHtml(e.target_detail||'')}')">
🔕
</button>
data-sup-type="${escHtml(supType)}"
data-sup-name="${escHtml(e.target_name)}"
data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button>
</td>
</tr>`;
}).join('');
@@ -204,12 +206,10 @@ function updateSuppressForm() {
if (detailGrp) detailGrp.style.display = (type === 'interface') ? '' : 'none';
}
function setDuration(mins) {
function setDuration(mins, el) {
document.getElementById('sup-expires').value = mins || '';
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
if (el) el.classList.add('active');
const hint = document.getElementById('duration-hint');
if (hint) {
if (mins) {
@@ -257,10 +257,21 @@ async function submitSuppress(e) {
}
}
// ── Close modal on backdrop click ────────────────────────────────────
// ── Global click handler: modal backdrop + suppress button delegation
document.addEventListener('click', e => {
// Close modal when clicking backdrop
const modal = document.getElementById('suppress-modal');
if (modal && e.target === modal) closeSuppressModal();
if (modal && e.target === modal) { closeSuppressModal(); return; }
// Suppress button via data attributes (avoids inline onclick XSS)
const btn = e.target.closest('.btn-suppress[data-sup-type]');
if (btn) {
openSuppressModal(
btn.dataset.supType || '',
btn.dataset.supName || '',
btn.dataset.supDetail || '',
);
}
});
// ── Utility ───────────────────────────────────────────────────────────

View File

@@ -31,7 +31,7 @@
--text: #00ff41;
--text-dim: #00cc33;
--text-muted: #008822;
--text-muted: #00bb33;
--font: 'Courier New','Consolas','Monaco','Menlo',monospace;
@@ -56,7 +56,7 @@ body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
font-size: 13px;
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
position: relative;
@@ -788,6 +788,31 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
.power-warn { background:var(--orange); }
.power-crit { background:var(--red); box-shadow:0 0 3px var(--red); }
/* Collapsible link panels */
.link-host-title {
cursor: pointer;
user-select: none;
}
.link-host-title:hover { background: rgba(0,255,65,.04); }
.panel-toggle {
font-size: .65em;
color: var(--text-muted);
letter-spacing: .04em;
flex-shrink: 0;
margin-left: 6px;
padding: 0 4px;
border: 1px solid rgba(0,255,65,.2);
}
.link-host-panel.collapsed > .link-ifaces-grid { display: none; }
/* Collapse all / Expand all bar */
.link-collapse-bar {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
/* Link panel states */
.link-no-data { padding:14px; color:var(--text-muted); font-size:.78em; text-align:center; }
.link-loading { padding:20px; text-align:center; color:var(--text-muted); font-size:.8em; }
@@ -797,6 +822,317 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
.counter-zero { color:var(--green); }
.counter-nonzero { color:var(--red); text-shadow:var(--glow-red); }
/* UniFi switch section divider */
.unifi-section-header {
display: flex;
align-items: center;
gap: 12px;
margin: 24px 0 12px;
color: var(--cyan);
font-size: .75em;
letter-spacing: .1em;
text-shadow: var(--glow-cyan);
}
.unifi-section-header::before,
.unifi-section-header::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
}
/* Port badges (UPLINK, PoE, #N) */
.port-badge {
font-size: .58em;
padding: 1px 5px;
border: 1px solid;
letter-spacing: .05em;
font-weight: bold;
vertical-align: middle;
}
.port-badge-uplink { color:var(--amber); border-color:var(--amber-dim); }
.port-badge-poe { color:var(--cyan); border-color:var(--cyan-dim); }
.port-badge-num { color:var(--text-muted); border-color:rgba(0,255,65,.2); }
/* LLDP neighbor + PoE info lines on link debug cards */
.port-lldp {
font-size: .68em;
color: var(--cyan);
text-shadow: var(--glow-cyan);
margin: -4px 0 6px;
letter-spacing: .02em;
}
.port-poe-info {
font-size: .68em;
color: var(--amber);
margin: -4px 0 6px;
letter-spacing: .02em;
}
/* Amber value colour used in inspector */
.val-amber { color:var(--amber); text-shadow:var(--glow-amber); }
/* Down port card — dim everything */
.link-iface-card.port-down {
opacity: .42;
filter: saturate(.3);
}
/* ── Inspector page ───────────────────────────────────────────────── */
/* Layout: main chassis area + collapsible right panel */
.inspector-layout {
display: flex;
gap: 16px;
align-items: flex-start;
min-height: 300px;
}
.inspector-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
/* Switch chassis card */
.inspector-chassis {
background: var(--bg2);
border: 1px solid var(--border);
position: relative;
}
.inspector-chassis::before { content:'╔'; position:absolute; top:-1px; left:-1px; color:var(--green); text-shadow:var(--glow); font-size:1rem; line-height:1; }
.inspector-chassis::after { content:'╗'; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:1rem; line-height:1; }
.chassis-header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--bg3);
border-bottom: 1px solid var(--border);
}
.chassis-name { font-weight:bold; font-size:.88em; color:var(--amber); text-shadow:var(--glow-amber); letter-spacing:.05em; }
.chassis-name::before { content:'>> '; color:var(--green); }
.chassis-ip { font-size:.72em; color:var(--text-muted); }
.chassis-meta { font-size:.65em; color:var(--text-muted); margin-left:auto; }
.chassis-body {
padding: 12px 16px 14px;
}
/* Port rows */
.chassis-rows { display:flex; flex-direction:column; gap:5px; margin-bottom:8px; }
.chassis-row { display:flex; flex-wrap:wrap; gap:4px; }
/* SFP section below main rows */
.chassis-sfp-section {
display: flex;
gap: 6px;
padding-top: 8px;
border-top: 1px solid rgba(0,255,255,.15);
margin-top: 4px;
}
/* Individual port block */
.switch-port-block {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
font-size: .6em;
font-weight: bold;
border: 1px solid;
cursor: pointer;
transition: box-shadow .1s, border-color .1s, background .1s;
user-select: none;
flex-shrink: 0;
letter-spacing: 0;
}
/* SFP port (in rows — slightly narrower to suggest cage) */
.switch-port-block.sfp-port {
width: 28px;
height: 38px;
font-size: .55em;
}
/* SFP section block (standalone cage) */
.switch-port-block.sfp-block {
width: 44px;
height: 30px;
font-size: .55em;
letter-spacing: .04em;
}
/* State colours */
.switch-port-block.down {
background: var(--bg3);
border-color: rgba(0,255,65,.15);
color: rgba(0,255,65,.25);
}
.switch-port-block.up {
background: rgba(0,255,65,.06);
border-color: var(--green-muted);
color: var(--green);
text-shadow: 0 0 4px rgba(0,255,65,.5);
}
.switch-port-block.up:hover {
background: rgba(0,255,65,.13);
border-color: var(--green);
box-shadow: var(--glow);
}
.switch-port-block.poe-active {
background: var(--amber-dim);
border-color: var(--amber);
color: var(--amber);
text-shadow: 0 0 4px rgba(255,176,0,.5);
}
.switch-port-block.poe-active:hover {
box-shadow: var(--glow-amber);
}
.switch-port-block.uplink {
background: var(--cyan-dim);
border-color: var(--cyan);
color: var(--cyan);
text-shadow: 0 0 4px rgba(0,255,255,.5);
}
.switch-port-block.uplink:hover {
box-shadow: var(--glow-cyan);
}
.switch-port-block.selected {
outline: 2px solid #fff;
outline-offset: 1px;
}
/* Right-side detail panel */
.inspector-panel {
width: 0;
overflow: hidden;
flex-shrink: 0;
transition: width .2s ease;
display: flex;
flex-direction: column;
}
.inspector-panel.open {
width: 310px;
}
.inspector-panel-inner {
width: 310px;
background: var(--bg2);
border: 1px solid var(--border);
padding: 14px 14px 18px;
position: relative;
overflow-y: auto;
max-height: calc(100vh - 120px);
}
.inspector-panel-inner::before { content:'╔'; position:absolute; top:-1px; left:-1px; color:var(--green); text-shadow:var(--glow); font-size:1rem; line-height:1; }
.inspector-panel-inner::after { content:'╗'; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:1rem; line-height:1; }
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.panel-port-name { font-weight:bold; font-size:.92em; color:var(--amber); text-shadow:var(--glow-amber); }
.panel-meta { font-size:.68em; color:var(--text-muted); margin-top:2px; }
.panel-close {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
font-size: .8em;
padding: 1px 7px;
font-family: var(--font);
flex-shrink: 0;
transition: all .15s;
}
.panel-close:hover { color:var(--red); border-color:var(--red); }
.panel-section-title {
font-size: .62em;
font-weight: bold;
color: var(--amber);
text-shadow: var(--glow-amber);
text-transform: uppercase;
letter-spacing: .1em;
margin: 10px 0 5px;
padding-bottom: 3px;
border-bottom: 1px solid rgba(0,255,65,.12);
}
.panel-section-title:first-of-type { margin-top: 0; }
.panel-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 2px 0;
}
.panel-label { font-size:.68em; color:var(--text-muted); text-transform:uppercase; letter-spacing:.05em; flex-shrink:0; }
.panel-val { font-size:.75em; font-weight:bold; color:var(--text-dim); text-align:right; word-break:break-all; }
/* Path debug two-column layout */
.path-conn-type {
font-size: .68em;
color: var(--cyan);
font-weight: normal;
margin-left: 6px;
text-shadow: none;
text-transform: none;
letter-spacing: normal;
}
.path-debug-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 6px;
}
.path-col {
background: var(--bg3);
border: 1px solid rgba(0,255,65,.18);
padding: 7px 8px;
}
.path-col-header {
font-size: .62em;
font-weight: bold;
color: var(--amber);
margin-bottom: 5px;
padding-bottom: 3px;
border-bottom: 1px solid rgba(0,255,65,.15);
letter-spacing: .04em;
}
.path-row {
display: flex;
justify-content: space-between;
gap: 4px;
font-size: .65em;
padding: 1px 0;
}
.path-row span:first-child { color:var(--text-muted); flex-shrink:0; }
.path-row span:last-child { color:var(--text-dim); font-weight:bold; text-align:right; word-break:break-all; }
.path-dom {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(0,255,255,.15);
}
.path-dom-row {
display: flex;
justify-content: space-between;
font-size: .65em;
padding: 1px 0;
color: var(--cyan);
}
.path-dom-row span:first-child { color:var(--text-muted); }
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.host-grid { grid-template-columns:1fr; }
@@ -806,4 +1142,7 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
.link-ifaces-grid { grid-template-columns:1fr; }
.sfp-grid { grid-template-columns:1fr 1fr; }
.header-nav { display:none; }
.inspector-layout { flex-direction:column; }
.inspector-panel.open { width:100%; }
.inspector-panel-inner { width:100%; }
}

View File

@@ -22,6 +22,10 @@
class="nav-link {% if request.endpoint == 'links_page' %}active{% endif %}">
Link Debug
</a>
<a href="{{ url_for('inspector') }}"
class="nav-link {% if request.endpoint == 'inspector' %}active{% endif %}">
Inspector
</a>
<a href="{{ url_for('suppressions_page') }}"
class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
Suppressions

View File

@@ -116,7 +116,9 @@
<div class="host-actions">
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('host', '{{ name }}', '')"
data-sup-type="host"
data-sup-name="{{ name }}"
data-sup-detail=""
title="Suppress alerts for this host">
🔕 Suppress
</button>
@@ -164,7 +166,9 @@
<td>
{% if not d.connected %}
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('unifi_device', '{{ d.name }}', '')">
data-sup-type="unifi_device"
data-sup-name="{{ d.name }}"
data-sup-detail="">
🔕 Suppress
</button>
{% endif %}
@@ -221,7 +225,9 @@
</td>
<td>
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}', '{{ e.target_name }}', '{{ e.target_detail or '' }}')"
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
data-sup-name="{{ e.target_name }}"
data-sup-detail="{{ e.target_detail or '' }}"
title="Suppress">🔕</button>
</td>
</tr>
@@ -271,11 +277,11 @@
<div class="form-group" style="margin-bottom:0">
<label>Duration</label>
<div class="duration-pills">
<button type="button" class="pill" onclick="setDuration(30)">30 min</button>
<button type="button" class="pill" onclick="setDuration(60)">1 hr</button>
<button type="button" class="pill" onclick="setDuration(240)">4 hr</button>
<button type="button" class="pill" onclick="setDuration(480)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDuration(null)">Manual ∞</button>
<button type="button" class="pill" onclick="setDuration(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button>
<button type="button" class="pill" onclick="setDuration(240, this)">4 hr</button>
<button type="button" class="pill" onclick="setDuration(480, this)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button>
</div>
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="form-hint" id="duration-hint">Persists until manually removed.</div>

391
templates/inspector.html Normal file
View File

@@ -0,0 +1,391 @@
{% extends "base.html" %}
{% block title %}Inspector GANDALF{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Network Inspector</h1>
<p class="page-sub">
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
<span id="inspector-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
</p>
</div>
<div class="inspector-layout">
<div class="inspector-main" id="inspector-main">
<div class="link-loading">Loading inspector data</div>
</div>
<div class="inspector-panel" id="inspector-panel">
<div class="inspector-panel-inner" id="inspector-panel-inner"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── Switch layout config ─────────────────────────────────────────────────
// keys match the model field returned by the UniFi API
// rows: array of rows, each row is an array of port_idx values
// sfp_ports: port_idx values that are SFP cages (in rows, rendered differently)
// sfp_section: port_idx values rendered as a separate SFP bank below the rows
const SWITCH_LAYOUTS = {
'USF5P': { rows: [[1,2,3,4]], sfp_section: [5] },
'USL8A': { rows: [[1,2,3,4],[5,6,7,8]], sfp_ports: [1,2,3,4,5,6,7,8] },
'US24PRO': {
rows: [
[1,3,5,7,9,11,13,15,17,19,21,23],
[2,4,6,8,10,12,14,16,18,20,22,24],
],
sfp_section: [25,26],
},
'USPPDUP': { rows: [[1]] },
'USMINI': { rows: [[1,2,3,4,5]] },
};
// ── Formatting helpers ───────────────────────────────────────────────────
function fmtSpeed(mbps) {
if (!mbps) return '';
if (mbps >= 1000) return (mbps / 1000).toFixed(0) + 'G';
return mbps + 'M';
}
function fmtRate(bytesPerSec) {
if (bytesPerSec === null || bytesPerSec === undefined) return '';
const bps = bytesPerSec * 8;
if (bps < 1e3) return bps.toFixed(0) + ' bps';
if (bps < 1e6) return (bps / 1e3).toFixed(1) + ' Kbps';
if (bps < 1e9) return (bps / 1e6).toFixed(2) + ' Mbps';
return (bps / 1e9).toFixed(3) + ' Gbps';
}
function fmtErrors(rate) {
if (rate === null || rate === undefined) return '';
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
}
// ── Build port_idx → port data map ──────────────────────────────────────
function buildPortIdxMap(ports) {
const map = {};
for (const [pname, d] of Object.entries(ports)) {
if (d.port_idx != null) {
map[d.port_idx] = Object.assign({ name: pname }, d);
}
}
return map;
}
// ── Determine port block CSS state class ────────────────────────────────
function portBlockState(d) {
if (!d || !d.up) return 'down';
if (d.is_uplink) return 'uplink';
if (d.poe_power != null && d.poe_power > 0) return 'poe-active';
return 'up';
}
// ── Render a single port block element ──────────────────────────────────
function portBlockHtml(idx, port, swName, sfpBlock) {
const state = portBlockState(port);
const label = sfpBlock ? 'SFP' : idx;
const title = port ? escHtml(port.name) : `Port ${idx}`;
const sfpCls = sfpBlock ? ' sfp-block' : '';
return `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}"
onclick="selectPort(this)">${label}</div>`;
}
// ── Render one switch chassis ────────────────────────────────────────────
function renderChassis(swName, sw) {
const model = sw.model || '';
const layout = SWITCH_LAYOUTS[model] || null;
const portMap = buildPortIdxMap(sw.ports || {});
const upCount = Object.values(sw.ports || {}).filter(p => p.up).length;
const totCount = Object.keys(sw.ports || {}).length;
const downCount = totCount - upCount;
const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · ');
let chassisHtml = '';
if (layout) {
const sfpPortSet = new Set(layout.sfp_ports || []);
const sfpSectionSet = new Set(layout.sfp_section || []);
// Main port rows
chassisHtml += '<div class="chassis-rows">';
for (const row of layout.rows) {
chassisHtml += '<div class="chassis-row">';
for (const idx of row) {
const port = portMap[idx];
const isSfp = sfpPortSet.has(idx);
const sfpCls = isSfp ? ' sfp-port' : '';
const state = portBlockState(port);
const title = port ? escHtml(port.name) : `Port ${idx}`;
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}"
onclick="selectPort(this)">${idx}</div>`;
}
chassisHtml += '</div>';
}
chassisHtml += '</div>';
// Separate SFP section (if present)
if (sfpSectionSet.size) {
chassisHtml += '<div class="chassis-sfp-section">';
for (const idx of layout.sfp_section) {
chassisHtml += portBlockHtml(idx, portMap[idx], swName, true);
}
chassisHtml += '</div>';
}
} else {
// Fallback: render all ports sorted by idx
const allPorts = Object.entries(sw.ports || {})
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0));
chassisHtml += '<div class="chassis-rows"><div class="chassis-row">';
for (const [pname, d] of allPorts) {
chassisHtml += portBlockHtml(d.port_idx || 0, Object.assign({ name: pname }, d), swName, false);
}
chassisHtml += '</div></div>';
}
return `
<div class="inspector-chassis" id="chassis-${escHtml(swName)}">
<div class="chassis-header">
<span class="chassis-name">${escHtml(swName)}</span>
${sw.ip ? `<span class="chassis-ip">${escHtml(sw.ip)}</span>` : ''}
<span class="chassis-meta">${escHtml(meta)}</span>
</div>
<div class="chassis-body">${chassisHtml}</div>
</div>`;
}
// ── State ────────────────────────────────────────────────────────────────
let _selectedSwitch = null;
let _selectedIdx = null;
let _apiData = null;
// ── Port selection ───────────────────────────────────────────────────────
function selectPort(el) {
const swName = el.dataset.switch;
const idx = parseInt(el.dataset.portIdx, 10);
document.querySelectorAll('.switch-port-block.selected')
.forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
_selectedSwitch = swName;
_selectedIdx = idx;
renderPanel(swName, idx);
}
function closePanel() {
document.getElementById('inspector-panel').classList.remove('open');
document.querySelectorAll('.switch-port-block.selected')
.forEach(el => el.classList.remove('selected'));
_selectedSwitch = null;
_selectedIdx = null;
}
// ── Render detail panel ──────────────────────────────────────────────────
function renderPanel(swName, idx) {
if (!_apiData) return;
const sw = _apiData.unifi_switches && _apiData.unifi_switches[swName];
if (!sw) return;
const portMap = buildPortIdxMap(sw.ports || {});
const d = portMap[idx];
if (!d) return;
const upStr = d.up ? '<span class="val-good">UP</span>' : '<span class="val-crit">DOWN</span>';
const speedStr = d.speed_mbps ? `<span class="val-cyan">${fmtSpeed(d.speed_mbps)}bps</span>` : '';
const duplexCls = d.full_duplex ? 'val-good' : (d.up ? 'val-warn' : 'val-neutral');
const duplexStr = d.up ? `<span class="${duplexCls}">${d.full_duplex ? 'Full' : 'Half'}</span>` : '';
const autoneg = d.autoneg ? 'On' : 'Off';
const mediaStr = d.media || '';
const isUplinkBadge = d.is_uplink ? ' <span class="port-badge port-badge-uplink">UPLINK</span>' : '';
// PoE section
let poeHtml = '';
if (d.poe_class != null) {
const poeMaxStr = d.poe_max_power != null ? ` / max ${d.poe_max_power.toFixed(1)}W` : '';
const poeCurStr = (d.poe_power != null && d.poe_power > 0) ? ` / draw <span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '';
poeHtml = `
<div class="panel-section-title">PoE</div>
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${d.poe_class}${poeMaxStr}</span></div>
${d.poe_power != null ? `<div class="panel-row"><span class="panel-label">Draw</span><span class="panel-val">${d.poe_power > 0 ? `<span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '0W'}</span></div>` : ''}
${d.poe_mode ? `<div class="panel-row"><span class="panel-label">Mode</span><span class="panel-val">${escHtml(d.poe_mode)}</span></div>` : ''}`;
}
// Traffic section
let trafficHtml = '';
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
trafficHtml = `
<div class="panel-section-title">Traffic</div>
<div class="panel-row"><span class="panel-label">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
}
// Errors / drops section
let errHtml = '';
if (d.tx_errs_rate != null || d.rx_errs_rate != null) {
errHtml = `
<div class="panel-section-title">Errors / Drops</div>
<div class="panel-row"><span class="panel-label">TX Err</span><span class="panel-val">${fmtErrors(d.tx_errs_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX Err</span><span class="panel-val">${fmtErrors(d.rx_errs_rate)}</span></div>
<div class="panel-row"><span class="panel-label">TX Drop</span><span class="panel-val">${fmtErrors(d.tx_drops_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX Drop</span><span class="panel-val">${fmtErrors(d.rx_drops_rate)}</span></div>`;
}
// LLDP + path debug
let lldpHtml = '';
let pathHtml = '';
if (d.lldp && d.lldp.system_name) {
const l = d.lldp;
lldpHtml = `
<div class="panel-section-title">LLDP Neighbor</div>
<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan">${escHtml(l.system_name)}</span></div>
${l.port_id ? `<div class="panel-row"><span class="panel-label">Port</span><span class="panel-val">${escHtml(l.port_id)}</span></div>` : ''}
${l.port_desc ? `<div class="panel-row"><span class="panel-label">Port Desc</span><span class="panel-val">${escHtml(l.port_desc)}</span></div>` : ''}
${l.chassis_id ? `<div class="panel-row"><span class="panel-label">Chassis</span><span class="panel-val">${escHtml(l.chassis_id)}</span></div>` : ''}
${l.mgmt_ips && l.mgmt_ips.length ? `<div class="panel-row"><span class="panel-label">Mgmt IP</span><span class="panel-val">${escHtml(l.mgmt_ips.join(', '))}</span></div>` : ''}`;
// Path debug: look for matching server interface
const hosts = _apiData.hosts || {};
const serverIfaces = hosts[l.system_name];
if (serverIfaces) {
let matchedIface = l.port_id && serverIfaces[l.port_id] ? l.port_id : null;
if (!matchedIface && l.port_id) {
// fuzzy match
matchedIface = Object.keys(serverIfaces).find(k => l.port_id.includes(k) || k.includes(l.port_id)) || null;
}
if (matchedIface) {
pathHtml = buildPathDebug(swName, d, l.system_name, matchedIface, serverIfaces[matchedIface]);
}
}
}
const inner = document.getElementById('inspector-panel-inner');
inner.innerHTML = `
<div class="panel-header">
<div>
<span class="panel-port-name">${escHtml(d.name)}</span>${isUplinkBadge}
<div class="panel-meta">${escHtml(swName)} · port #${idx}</div>
</div>
<button class="panel-close" onclick="closePanel()">✕</button>
</div>
<div class="panel-section-title">Link</div>
<div class="panel-row"><span class="panel-label">Status</span><span class="panel-val">${upStr}</span></div>
<div class="panel-row"><span class="panel-label">Speed</span><span class="panel-val">${speedStr}</span></div>
<div class="panel-row"><span class="panel-label">Duplex</span><span class="panel-val">${duplexStr}</span></div>
<div class="panel-row"><span class="panel-label">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</span></div>
<div class="panel-row"><span class="panel-label">Media</span><span class="panel-val">${escHtml(mediaStr)}</span></div>
${poeHtml}
${trafficHtml}
${errHtml}
${lldpHtml}
${pathHtml}
`;
document.getElementById('inspector-panel').classList.add('open');
}
// ── Build path debug two-column section ─────────────────────────────────
function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
const isFiber = (swPort.media || '').toLowerCase().includes('sfp') ||
(svrData.port_type || '').toLowerCase().includes('fibre') ||
(svrData.port_type || '').toLowerCase().includes('fiber') ||
!!svrData.sfp;
const connType = isFiber ? 'SFP / Fiber' : 'Copper';
let sfpDomHtml = '';
if (svrData.sfp && Object.keys(svrData.sfp).length) {
const sfp = svrData.sfp;
sfpDomHtml = '<div class="path-dom">';
if (sfp.vendor) sfpDomHtml += `<div class="path-dom-row"><span>Vendor</span><span>${escHtml(sfp.vendor)}${sfp.part_no ? ' / ' + escHtml(sfp.part_no) : ''}</span></div>`;
if (sfp.temp_c != null) sfpDomHtml += `<div class="path-dom-row"><span>Temp</span><span>${sfp.temp_c.toFixed(1)}°C</span></div>`;
if (sfp.tx_power_dbm != null) sfpDomHtml += `<div class="path-dom-row"><span>TX</span><span>${sfp.tx_power_dbm.toFixed(2)} dBm</span></div>`;
if (sfp.rx_power_dbm != null) sfpDomHtml += `<div class="path-dom-row"><span>RX</span><span>${sfp.rx_power_dbm.toFixed(2)} dBm</span></div>`;
sfpDomHtml += '</div>';
}
const svrErrTx = (svrData.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
const svrErrRx = (svrData.rx_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';
return `
<div class="panel-section-title">Path Debug <span class="path-conn-type">${escHtml(connType)}</span></div>
<div class="path-debug-cols">
<div class="path-col">
<div class="path-col-header">Switch</div>
<div class="path-row"><span>Port</span><span>${escHtml(swPort.name)}</span></div>
<div class="path-row"><span>Speed</span><span>${fmtSpeed(swPort.speed_mbps)}bps</span></div>
<div class="path-row"><span>TX</span><span>${fmtRate(swPort.tx_bytes_rate)}</span></div>
<div class="path-row"><span>RX</span><span>${fmtRate(swPort.rx_bytes_rate)}</span></div>
<div class="path-row"><span>TX Err</span><span class="${swErrTx}">${fmtErrors(swPort.tx_errs_rate)}</span></div>
<div class="path-row"><span>RX Err</span><span class="${swErrRx}">${fmtErrors(swPort.rx_errs_rate)}</span></div>
${(swPort.poe_power != null && swPort.poe_power > 0) ? `<div class="path-row"><span>PoE</span><span class="val-amber">${swPort.poe_power.toFixed(1)}W</span></div>` : ''}
</div>
<div class="path-col">
<div class="path-col-header">Server: ${escHtml(serverName)}</div>
<div class="path-row"><span>Iface</span><span>${escHtml(ifaceName)}</span></div>
<div class="path-row"><span>Speed</span><span>${svrData.speed_mbps ? fmtSpeed(svrData.speed_mbps) + 'bps' : ''}</span></div>
<div class="path-row"><span>TX</span><span>${fmtRate(svrData.tx_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>RX Err</span><span class="${svrErrRx}">${fmtErrors(svrData.rx_errs_rate)}</span></div>
${sfpDomHtml}
</div>
</div>`;
}
// ── Render all switches ──────────────────────────────────────────────────
function renderInspector(data) {
_apiData = data;
const main = document.getElementById('inspector-main');
const switches = data.unifi_switches || {};
const upd = data.updated ? `Updated: ${data.updated}` : '';
const updEl = document.getElementById('inspector-updated');
if (updEl) updEl.textContent = upd;
if (!Object.keys(switches).length) {
main.innerHTML = '<p class="empty-state">No switch data available. Monitor may still be initialising.</p>';
return;
}
main.innerHTML = Object.entries(switches)
.map(([swName, sw]) => renderChassis(swName, sw))
.join('');
// Re-apply selection highlight after re-render (dataset compare — no CSS escaping)
if (_selectedSwitch && _selectedIdx !== null) {
const block = Array.from(document.querySelectorAll('.switch-port-block')).find(
el => el.dataset.switch === _selectedSwitch && parseInt(el.dataset.portIdx, 10) === _selectedIdx
);
if (block) {
block.classList.add('selected');
renderPanel(_selectedSwitch, _selectedIdx);
}
}
}
// ── Fetch and render ─────────────────────────────────────────────────────
async function loadInspector() {
try {
const resp = await fetch('/api/links');
if (!resp.ok) throw new Error('API error');
const data = await resp.json();
renderInspector(data);
} catch (e) {
document.getElementById('inspector-main').innerHTML =
'<p class="empty-state">Failed to load inspector data.</p>';
}
}
loadInspector();
setInterval(loadInspector, 60000);
</script>
{% endblock %}

View File

@@ -7,7 +7,7 @@
<h1 class="page-title">Link Debug</h1>
<p class="page-sub">
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
Data collected via Prometheus node_exporter + SSH ethtool every poll cycle.
Data collected via Prometheus node_exporter + SSH ethtool (servers) and UniFi API (switches) every poll cycle.
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
</p>
</div>
@@ -262,10 +262,177 @@ function renderIfaceCard(ifaceName, d) {
</div>`;
}
// ── Render a single UniFi switch port card ────────────────────────
function renderPortCard(portName, d) {
const up = d.up;
const speed = up ? fmtSpeed(d.speed_mbps) : 'DOWN';
const duplex = d.full_duplex ? 'Full' : (up ? 'Half' : '');
const media = d.media || '';
const uplinkBadge = d.is_uplink
? '<span class="port-badge port-badge-uplink">UPLINK</span>' : '';
const poeBadge = (d.poe_power != null && d.poe_power > 0)
? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
const numBadge = d.port_idx
? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
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>` : '';
const txRate = d.tx_bytes_rate;
const rxRate = d.rx_bytes_rate;
const txPct = fmtRateBar(txRate, d.speed_mbps);
const rxPct = fmtRateBar(rxRate, d.speed_mbps);
const txStr = fmtRate(txRate);
const rxStr = fmtRate(rxRate);
return `
<div class="link-iface-card${up ? '' : ' port-down'}">
<div class="link-iface-header">
<span class="link-iface-name">${escHtml(portName)}</span>
<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>` : ''}
</div>
${lldpHtml}${poeMaxHtml}
<div class="link-stats-grid">
<div class="link-stat">
<span class="link-stat-label">Duplex</span>
<span class="link-stat-value ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${duplex}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">Auto-neg</span>
<span class="link-stat-value val-neutral">${d.autoneg ? 'On' : 'Off'}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">TX Errors</span>
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">RX Errors</span>
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">TX Drops</span>
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">RX Drops</span>
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
</div>
</div>
${(up && (txRate != null || rxRate != null)) ? `
<div class="traffic-section">
<div class="traffic-row">
<span class="traffic-label">TX</span>
<div class="traffic-bar-track">
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
</div>
<span class="traffic-value">${txStr}</span>
</div>
<div class="traffic-row">
<span class="traffic-label">RX</span>
<div class="traffic-bar-track">
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
</div>
<span class="traffic-value">${rxStr}</span>
</div>
</div>` : ''}
</div>`;
}
// ── Render UniFi switches section ─────────────────────────────────
function renderUnifiSwitches(unifiSwitches) {
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
const panels = Object.entries(unifiSwitches).map(([swName, sw]) => {
const ports = sw.ports || {};
const allPorts= Object.entries(ports)
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0));
const upCount = allPorts.filter(([,d]) => d.up).length;
const downCount = allPorts.length - upCount;
const portCards = allPorts
.map(([pname, d]) => renderPortCard(pname, d))
.join('');
const meta = [
sw.model,
`${upCount} up`,
downCount ? `${downCount} down` : '',
].filter(Boolean).join(' · ');
return `
<div class="link-host-panel" id="unifi-${escHtml(swName)}">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
<span class="link-host-name">${escHtml(swName)}</span>
${sw.ip ? `<span class="link-host-ip">${escHtml(sw.ip)}</span>` : ''}
<span class="link-host-upd">${escHtml(meta)}</span>
<span class="panel-toggle" title="Collapse / expand">[]</span>
</div>
<div class="link-ifaces-grid">
${portCards || '<div class="link-no-data">No port data available.</div>'}
</div>
</div>`;
}).join('');
return `
<div class="unifi-section-header">UniFi Switches</div>
<div class="link-host-list">${panels}</div>`;
}
// ── Collapse / expand panels ───────────────────────────────────────
function togglePanel(panel) {
panel.classList.toggle('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]';
const id = panel.id;
if (id) {
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = panel.classList.contains('collapsed');
sessionStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
}
}
function restoreCollapseState() {
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
for (const [id, collapsed] of Object.entries(saved)) {
if (!collapsed) continue;
const panel = document.getElementById(id);
if (panel) {
panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]';
}
}
}
function collapseAll() {
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]';
});
sessionStorage.setItem('gandalfCollapsed', '{}'); // let restore pick it up next time
}
function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.remove('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]';
});
sessionStorage.setItem('gandalfCollapsed', '{}');
}
// ── Render all hosts ──────────────────────────────────────────────
function renderLinks(data) {
const hosts = data.hosts || {};
if (!Object.keys(hosts).length) {
const unifi = data.unifi_switches || {};
if (!Object.keys(hosts).length && !Object.keys(unifi).length) {
document.getElementById('links-container').innerHTML =
'<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>';
return;
@@ -275,7 +442,7 @@ function renderLinks(data) {
const updEl = document.getElementById('links-updated');
if (updEl) updEl.textContent = upd;
const html = Object.entries(hosts).map(([hostName, ifaces]) => {
const serverHtml = Object.entries(hosts).map(([hostName, ifaces]) => {
const ifaceCards = Object.entries(ifaces)
.sort(([a],[b]) => a.localeCompare(b))
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d))
@@ -284,9 +451,10 @@ function renderLinks(data) {
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || '';
return `
<div class="link-host-panel" id="${escHtml(hostName)}">
<div class="link-host-title">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
<span class="link-host-name">${escHtml(hostName)}</span>
${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</span>` : ''}
<span class="panel-toggle" title="Collapse / expand">[]</span>
</div>
<div class="link-ifaces-grid">
${ifaceCards || '<div class="link-no-data">No interface data available.</div>'}
@@ -295,12 +463,22 @@ function renderLinks(data) {
}).join('');
document.getElementById('links-container').innerHTML =
`<div class="link-host-list">${html}</div>`;
`<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="expandAll()">Expand all</button>
</div>` +
`<div class="link-host-list">${serverHtml}</div>` +
renderUnifiSwitches(unifi);
restoreCollapseState();
// Jump to anchor if URL has #hostname
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) el.scrollIntoView({behavior:'smooth', block:'start'});
if (el) {
if (el.classList.contains('collapsed')) togglePanel(el);
el.scrollIntoView({behavior:'smooth', block:'start'});
}
}
}

View File

@@ -50,11 +50,11 @@
<div class="form-group">
<label>Duration</label>
<div class="duration-pills">
<button type="button" class="pill" onclick="setDur(30)">30 min</button>
<button type="button" class="pill" onclick="setDur(60)">1 hr</button>
<button type="button" class="pill" onclick="setDur(240)">4 hr</button>
<button type="button" class="pill" onclick="setDur(480)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDur(null)">Manual ∞</button>
<button type="button" class="pill" onclick="setDur(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDur(60, this)">1 hr</button>
<button type="button" class="pill" onclick="setDur(240, this)">4 hr</button>
<button type="button" class="pill" onclick="setDur(480, this)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button>
</div>
<input type="hidden" id="s-expires" name="expires_minutes" value="">
<div class="form-hint" id="s-dur-hint">Persists until manually removed.</div>
@@ -86,7 +86,7 @@
{% for s in active %}
<tr id="sup-row-{{ s.id }}">
<td><span class="badge badge-info">{{ s.target_type }}</span></td>
<td>{{ s.target_name or '<em>all</em>' | safe }}</td>
<td>{{ s.target_name or 'all' }}</td>
<td>{{ s.target_detail or '' }}</td>
<td>{{ s.reason }}</td>
<td>{{ s.suppressed_by }}</td>