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 log.txt
config.json
__pycache__/
*.pyc

429
README.md
View File

@@ -1,6 +1,6 @@
# GANDALF (Global Advanced Network Detection And Link Facilitator) # 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. 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`.
@@ -9,7 +9,7 @@ Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusgu
## Architecture ## Architecture
Gandalf is two processes that share a MariaDB database: Two processes share a MariaDB database:
| Process | Service | Role | | Process | Service | Role |
|---|---|---| |---|---|---|
@@ -18,21 +18,23 @@ Gandalf is two processes that share a MariaDB database:
``` ```
[Prometheus :9090] ──▶ [Prometheus :9090] ──▶
monitor.py ──▶ MariaDB ◀── app.py ──▶ nginx ──▶ Authelia ──▶ Browser [UniFi Controller] ──▶ monitor.py ──▶ MariaDB ◀── app.py ──▶ nginx ──▶ Authelia ──▶ Browser
[UniFi Controller] ──▶ [Pulse Worker] ──▶
[SSH / ethtool] ──▶
``` ```
### Data Sources ### 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 | | **Prometheus** (`10.10.10.48:9090`) | Physical NIC link state + traffic/error rates via `node_exporter` |
| **UniFi API** (`https://10.10.10.1`) | Switch, AP, and gateway device status | | **UniFi API** (`https://10.10.10.1`) | Switch port stats, device status, LLDP neighbor table, PoE data |
| **Ping** | pbs (10.10.10.3) — no node_exporter | | **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) ### Monitored Hosts (Prometheus / node_exporter)
| Host | Instance | | Host | Prometheus Instance |
|---|---| |---|---|
| large1 | 10.10.10.2:9100 | | large1 | 10.10.10.2:9100 |
| compute-storage-01 | 10.10.10.4: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 | | compute-storage-gpu-01 | 10.10.10.10:9100 |
| storage-01 | 10.10.10.11: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 ### Dashboard (`/`)
- **UniFi device monitoring** detects offline switches, APs, and gateways - Real-time host status grid with per-NIC link state (UP / DOWN / degraded)
- **Ping reachability** covers hosts without node_exporter - Network topology diagram (Internet → Gateway → Switches → Hosts)
- **Cluster-wide detection** creates a separate P1 ticket when 3+ hosts have simultaneous interface failures (likely a switch failure) - UniFi device table (switches, APs, gateway)
- **Smart baseline tracking** interfaces that are down on first observation (unused ports) are never alerted on; only regressions from UP→DOWN trigger tickets - Active alerts table with severity, target, consecutive failures, ticket link
- **Ticket creation** integrates with Tinker Tickets (`t.lotusguild.org`) with 24-hour deduplication - Quick-suppress modal: apply timed or manual suppression from any alert row
- **Alert suppression** manual toggle or timed windows (30min / 1hr / 4hr / 8hr / manual) - Auto-refreshes every 30 seconds via `/api/status` + `/api/network`
- **Authelia SSO** restricted to `admin` group via forward-auth headers
### 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 | | Condition | Priority |
|---|---| |---|---|
| UniFi device offline (2+ consecutive checks) | P2 High | | UniFi device offline (2 consecutive checks) | P2 High |
| Proxmox host NIC link-down regression (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 | | Host unreachable via ping (2 consecutive checks) | P2 High |
| 3+ hosts simultaneously reporting interface failures | P1 Critical | | 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 ### Suppression Targets
@@ -77,30 +153,88 @@ Gandalf is two processes that share a MariaDB database:
| `all` | Everything (global maintenance mode) | | `all` | Everything (global maintenance mode) |
Suppressions can be manual (persist until removed) or timed (auto-expire). 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 | | 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 | | `prometheus.url` | Prometheus base URL |
| `database.*` | MariaDB credentials | | `unifi.controller` | UniFi controller base URL (HTTPS, self-signed cert ignored) |
| `ticket_api.api_key` | Tinker Tickets Bearer token | | `unifi.api_key` | UniFi API key from controller Settings → API |
| `monitor.poll_interval` | Seconds between checks (default: 120) | | `unifi.site_id` | UniFi site ID (default: `default`) |
| `monitor.failure_threshold` | Consecutive failures before ticketing (default: 2) | | `ticket_api.api_key` | Tinker Tickets bearer token |
| `monitor.cluster_threshold` | Hosts with failures to trigger cluster alert (default: 3) | | `pulse.url` | Pulse worker API base URL (for SSH relay) |
| `monitor.ping_hosts` | Hosts checked via ping (no node_exporter) | | `pulse.worker_id` | Which Pulse worker runs ethtool collection |
| `hosts` | Maps Prometheus instance labels to hostnames | | `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) ## Deployment (LXC 157)
### 1. Database (MariaDB LXC 149 at 10.10.10.50) ### 1. Database MariaDB LXC 149 (`10.10.10.50`)
```sql ```sql
CREATE DATABASE gandalf CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 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; FLUSH PRIVILEGES;
``` ```
Then import the schema: Import schema:
```bash ```bash
mysql -h 10.10.10.50 -u gandalf -p gandalf < schema.sql mysql -h 10.10.10.50 -u gandalf -p gandalf < schema.sql
``` ```
### 2. LXC 157 Install dependencies ### 2. LXC 157 Install dependencies
```bash ```bash
pip3 install -r requirements.txt pip3 install -r requirements.txt
# Ensure sshpass is available (used by deploy scripts)
apt install sshpass
``` ```
### 3. Deploy files ### 3. Deploy files
```bash ```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 **`gandalf.service`** (Flask/gunicorn web app):
- `ticket_api.api_key` copy from tinker tickets admin panel ```ini
[Unit]
Description=Gandalf Web Dashboard
After=network.target
### 5. Install the monitor service [Service]
Type=simple
```bash WorkingDirectory=/var/www/html/prod
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:
```
ExecStart=/usr/bin/python3 -m gunicorn --workers 1 --bind 127.0.0.1:8000 app:app 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 ```yaml
access_control:
rules:
- domain: gandalf.lotusguild.org - domain: gandalf.lotusguild.org
policy: one_factor policy: one_factor
subject: subject:
- group:admin - group:admin
``` ```
Reload Authelia: `systemctl restart authelia` ```bash
systemctl restart authelia
```
### 7. NPM proxy host ### 6. NPM reverse proxy
- Domain: `gandalf.lotusguild.org` - **Domain:** `gandalf.lotusguild.org`
- Forward to: `http://10.10.10.61:80` (nginx on LXC 157) - **Forward to:** `http://10.10.10.61:8000` (gunicorn direct, no nginx needed on LXC)
- Enable Authelia forward auth - **Forward Auth:** Authelia at `http://10.10.10.167:9091`
- WebSockets: **not required** - **WebSockets:** Not required
--- ---
## Service Management ## Service Management
```bash ```bash
# Monitor daemon # Status
systemctl status gandalf-monitor systemctl status gandalf gandalf-monitor
# Logs (live)
journalctl -u gandalf -f
journalctl -u gandalf-monitor -f journalctl -u gandalf-monitor -f
# Web server # Restart after code or config changes
systemctl status gandalf systemctl restart gandalf gandalf-monitor
journalctl -u gandalf -f
# Restart both after config/code changes
systemctl restart gandalf-monitor gandalf
``` ```
--- ---
## Troubleshooting ## Troubleshooting
**Monitor not creating tickets** ### Monitor not creating tickets
- Check `config.json``ticket_api.api_key` is set - Verify `config.json → ticket_api.api_key` is set and valid
- Check `journalctl -u gandalf-monitor` for errors - 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** ### Link Debug shows no data / "Loading…" forever
- `interface_baseline` is stored in the `monitor_state` DB table; it persists across restarts - 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"** ### Link Debug: SFP DOM panel missing
- That interface was down on the first poll after the monitor started - SFP data requires Pulse worker + SSH access to hosts
- It will begin tracking once it comes up; or manually update the baseline in DB if needed - 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** ### Inspector: path debug section not appearing
- Verify node_exporter is running: `systemctl status prometheus-node-exporter` - Requires LLDP: run `apt install lldpd && systemctl enable --now lldpd` on each server
- Check Prometheus targets: `http://10.10.10.48:9090/targets` - 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) 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') @app.route('/suppressions')
@require_auth @require_auth
def suppressions_page(): def suppressions_page():

11
db.py
View File

@@ -1,6 +1,7 @@
"""Database operations for Gandalf network monitor.""" """Database operations for Gandalf network monitor."""
import json import json
import logging import logging
import threading
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -11,6 +12,7 @@ import pymysql.cursors
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_config_cache = None _config_cache = None
_local = threading.local()
def _config() -> dict: def _config() -> dict:
@@ -23,7 +25,10 @@ def _config() -> dict:
@contextmanager @contextmanager
def get_conn(): def get_conn():
"""Yield a per-thread cached database connection, reconnecting as needed."""
cfg = _config() cfg = _config()
conn = getattr(_local, 'conn', None)
if conn is None:
conn = pymysql.connect( conn = pymysql.connect(
host=cfg['host'], host=cfg['host'],
port=cfg.get('port', 3306), port=cfg.get('port', 3306),
@@ -35,10 +40,10 @@ def get_conn():
connect_timeout=10, connect_timeout=10,
charset='utf8mb4', charset='utf8mb4',
) )
try: _local.conn = conn
else:
conn.ping(reconnect=True)
yield conn 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 json
import logging import logging
import re import re
import shlex
import subprocess import subprocess
import time import time
from datetime import datetime from datetime import datetime
@@ -120,6 +121,70 @@ class UnifiClient:
logger.error(f'UniFi API error: {e}') logger.error(f'UniFi API error: {e}')
return None 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 # Ticket client
@@ -162,29 +227,90 @@ class TicketClient:
return None 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) # Link stats collector (ethtool + Prometheus traffic metrics)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
class LinkStatsCollector: 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 self.prom = prom
ssh = cfg.get('ssh', {}) self.pulse = PulseClient(cfg)
self.ssh_user = ssh.get('user', 'root') self.unifi = unifi
self.ssh_pass = ssh.get('password', '') # State for UniFi rate calculation (previous snapshot + timestamp)
self.ssh_connect_timeout = ssh.get('connect_timeout', 5) self._prev_unifi: Dict[str, dict] = {}
self.ssh_timeout = ssh.get('timeout', 20) 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]: 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 Delegate one SSH session to the Pulse worker to collect ethtool + SFP DOM
all *ifaces*. Returns {iface: {speed_mbps, duplex, ..., sfp: {...}}}. 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 {} return {}
# Validate interface names (kernel names only contain [a-zA-Z0-9_.-]) # 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 # Build a single shell command: for each iface output ethtool + -m with sentinels
parts = [] parts = []
for iface in safe_ifaces: for iface in safe_ifaces:
q = shlex.quote(iface)
parts.append( parts.append(
f'echo "___IFACE:{iface}___";' f'echo "___IFACE:{iface}___";'
f' ethtool "{iface}" 2>/dev/null;' f' ethtool {q} 2>/dev/null;'
f' echo "___DOM:{iface}___";' f' echo "___DOM:{iface}___";'
f' ethtool -m "{iface}" 2>/dev/null;' f' ethtool -m {q} 2>/dev/null;'
f' echo "___END___"' f' echo "___END___"'
) )
shell_cmd = ' '.join(parts) shell_cmd = ' '.join(parts)
try: ssh_cmd = (
result = subprocess.run( f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
[ f'-o LogLevel=ERROR root@{ip} "{shell_cmd}"'
'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,
) )
output = result.stdout output = self.pulse.run_command(ssh_cmd)
except FileNotFoundError: if output is None:
logger.debug('sshpass not found skipping ethtool collection') logger.debug(f'Pulse ethtool collection returned None for {ip}')
return {}
except Exception as e:
logger.debug(f'SSH ethtool {ip}: {e}')
return {} return {}
return self._parse_ssh_output(output) return self._parse_ssh_output(output)
@@ -415,9 +527,9 @@ class LinkStatsCollector:
host_ip = instance.split(':')[0] host_ip = instance.split(':')[0]
ifaces = list(iface_metrics.keys()) 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] = {} ethtool_data: Dict[str, dict] = {}
if self.ssh_pass and ifaces: if self.pulse.url and ifaces:
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:
@@ -438,11 +550,52 @@ class LinkStatsCollector:
result_hosts[host] = merged 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 { return {
'hosts': result_hosts, 'hosts': result_hosts,
'unifi_switches': unifi_switches,
'updated': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'), '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 # Helpers
@@ -479,7 +632,7 @@ class NetworkMonitor:
self.prom = PrometheusClient(prom_url) self.prom = PrometheusClient(prom_url)
self.unifi = UnifiClient(self.cfg['unifi']) self.unifi = UnifiClient(self.cfg['unifi'])
self.tickets = TicketClient(self.cfg.get('ticket_api', {})) 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', {}) mon = self.cfg.get('monitor', {})
self.poll_interval = mon.get('poll_interval', 120) self.poll_interval = mon.get('poll_interval', 120)

View File

@@ -105,7 +105,9 @@ function updateUnifiTable(devices) {
const statusText = d.connected ? 'Online' : 'Offline'; const statusText = d.connected ? 'Online' : 'Offline';
const suppressBtn = !d.connected const suppressBtn = !d.connected
? `<button class="btn-sm btn-suppress" ? `<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 ` return `
<tr class="${statusClass}"> <tr class="${statusClass}">
@@ -149,9 +151,9 @@ function updateEventsTable(events) {
<td>${ticket}</td> <td>${ticket}</td>
<td> <td>
<button class="btn-sm btn-suppress" <button class="btn-sm btn-suppress"
onclick="openSuppressModal('${supType}','${escHtml(e.target_name)}','${escHtml(e.target_detail||'')}')"> data-sup-type="${escHtml(supType)}"
🔕 data-sup-name="${escHtml(e.target_name)}"
</button> data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -204,12 +206,10 @@ function updateSuppressForm() {
if (detailGrp) detailGrp.style.display = (type === 'interface') ? '' : 'none'; if (detailGrp) detailGrp.style.display = (type === 'interface') ? '' : 'none';
} }
function setDuration(mins) { function setDuration(mins, el) {
document.getElementById('sup-expires').value = mins || ''; document.getElementById('sup-expires').value = mins || '';
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active')); 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'); const hint = document.getElementById('duration-hint');
if (hint) { if (hint) {
if (mins) { 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 => { document.addEventListener('click', e => {
// Close modal when clicking backdrop
const modal = document.getElementById('suppress-modal'); 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 ─────────────────────────────────────────────────────────── // ── Utility ───────────────────────────────────────────────────────────

View File

@@ -31,7 +31,7 @@
--text: #00ff41; --text: #00ff41;
--text-dim: #00cc33; --text-dim: #00cc33;
--text-muted: #008822; --text-muted: #00bb33;
--font: 'Courier New','Consolas','Monaco','Menlo',monospace; --font: 'Courier New','Consolas','Monaco','Menlo',monospace;
@@ -56,7 +56,7 @@ body {
font-family: var(--font); font-family: var(--font);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-size: 13px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
@@ -788,6 +788,31 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
.power-warn { background:var(--orange); } .power-warn { background:var(--orange); }
.power-crit { background:var(--red); box-shadow:0 0 3px var(--red); } .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 panel states */
.link-no-data { padding:14px; color:var(--text-muted); font-size:.78em; text-align:center; } .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; } .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-zero { color:var(--green); }
.counter-nonzero { color:var(--red); text-shadow:var(--glow-red); } .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 ───────────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.host-grid { grid-template-columns:1fr; } .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; } .link-ifaces-grid { grid-template-columns:1fr; }
.sfp-grid { grid-template-columns:1fr 1fr; } .sfp-grid { grid-template-columns:1fr 1fr; }
.header-nav { display:none; } .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 %}"> class="nav-link {% if request.endpoint == 'links_page' %}active{% endif %}">
Link Debug Link Debug
</a> </a>
<a href="{{ url_for('inspector') }}"
class="nav-link {% if request.endpoint == 'inspector' %}active{% endif %}">
Inspector
</a>
<a href="{{ url_for('suppressions_page') }}" <a href="{{ url_for('suppressions_page') }}"
class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}"> class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
Suppressions Suppressions

View File

@@ -116,7 +116,9 @@
<div class="host-actions"> <div class="host-actions">
<button class="btn-sm btn-suppress" <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"> title="Suppress alerts for this host">
🔕 Suppress 🔕 Suppress
</button> </button>
@@ -164,7 +166,9 @@
<td> <td>
{% if not d.connected %} {% if not d.connected %}
<button class="btn-sm btn-suppress" <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 🔕 Suppress
</button> </button>
{% endif %} {% endif %}
@@ -221,7 +225,9 @@
</td> </td>
<td> <td>
<button class="btn-sm btn-suppress" <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> title="Suppress">🔕</button>
</td> </td>
</tr> </tr>
@@ -271,11 +277,11 @@
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label>Duration</label> <label>Duration</label>
<div class="duration-pills"> <div class="duration-pills">
<button type="button" class="pill" onclick="setDuration(30)">30 min</button> <button type="button" class="pill" onclick="setDuration(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDuration(60)">1 hr</button> <button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button>
<button type="button" class="pill" onclick="setDuration(240)">4 hr</button> <button type="button" class="pill" onclick="setDuration(240, this)">4 hr</button>
<button type="button" class="pill" onclick="setDuration(480)">8 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)">Manual ∞</button> <button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button>
</div> </div>
<input type="hidden" id="sup-expires" name="expires_minutes" value=""> <input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="form-hint" id="duration-hint">Persists until manually removed.</div> <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> <h1 class="page-title">Link Debug</h1>
<p class="page-sub"> <p class="page-sub">
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes. 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> <span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
</p> </p>
</div> </div>
@@ -262,10 +262,177 @@ function renderIfaceCard(ifaceName, d) {
</div>`; </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 ────────────────────────────────────────────── // ── Render all hosts ──────────────────────────────────────────────
function renderLinks(data) { function renderLinks(data) {
const hosts = data.hosts || {}; 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 = document.getElementById('links-container').innerHTML =
'<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>'; '<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>';
return; return;
@@ -275,7 +442,7 @@ function renderLinks(data) {
const updEl = document.getElementById('links-updated'); const updEl = document.getElementById('links-updated');
if (updEl) updEl.textContent = upd; 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) const ifaceCards = Object.entries(ifaces)
.sort(([a],[b]) => a.localeCompare(b)) .sort(([a],[b]) => a.localeCompare(b))
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d)) .map(([ifaceName, d]) => renderIfaceCard(ifaceName, d))
@@ -284,9 +451,10 @@ function renderLinks(data) {
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || ''; const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || '';
return ` return `
<div class="link-host-panel" id="${escHtml(hostName)}"> <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> <span class="link-host-name">${escHtml(hostName)}</span>
${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</span>` : ''} ${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</span>` : ''}
<span class="panel-toggle" title="Collapse / expand">[]</span>
</div> </div>
<div class="link-ifaces-grid"> <div class="link-ifaces-grid">
${ifaceCards || '<div class="link-no-data">No interface data available.</div>'} ${ifaceCards || '<div class="link-no-data">No interface data available.</div>'}
@@ -295,12 +463,22 @@ function renderLinks(data) {
}).join(''); }).join('');
document.getElementById('links-container').innerHTML = 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 // Jump to anchor if URL has #hostname
if (location.hash) { if (location.hash) {
const el = document.querySelector(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"> <div class="form-group">
<label>Duration</label> <label>Duration</label>
<div class="duration-pills"> <div class="duration-pills">
<button type="button" class="pill" onclick="setDur(30)">30 min</button> <button type="button" class="pill" onclick="setDur(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDur(60)">1 hr</button> <button type="button" class="pill" onclick="setDur(60, this)">1 hr</button>
<button type="button" class="pill" onclick="setDur(240)">4 hr</button> <button type="button" class="pill" onclick="setDur(240, this)">4 hr</button>
<button type="button" class="pill" onclick="setDur(480)">8 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)">Manual ∞</button> <button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button>
</div> </div>
<input type="hidden" id="s-expires" name="expires_minutes" value=""> <input type="hidden" id="s-expires" name="expires_minutes" value="">
<div class="form-hint" id="s-dur-hint">Persists until manually removed.</div> <div class="form-hint" id="s-dur-hint">Persists until manually removed.</div>
@@ -86,7 +86,7 @@
{% for s in active %} {% for s in active %}
<tr id="sup-row-{{ s.id }}"> <tr id="sup-row-{{ s.id }}">
<td><span class="badge badge-info">{{ s.target_type }}</span></td> <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.target_detail or '' }}</td>
<td>{{ s.reason }}</td> <td>{{ s.reason }}</td>
<td>{{ s.suppressed_by }}</td> <td>{{ s.suppressed_by }}</td>