diff --git a/.flake8 b/.flake8
index 5a5abd8..50b9f61 100644
--- a/.flake8
+++ b/.flake8
@@ -1,7 +1,7 @@
[flake8]
max-line-length = 120
-# E221: multiple spaces before operator — intentional column alignment
-# E305: two blank lines after function — relaxed for module-level code
+# E221: multiple spaces before operator: intentional column alignment
+# E305: two blank lines after function: relaxed for module-level code
extend-ignore = E221, E305
exclude = __pycache__, .git, node_modules
extend-exclude = node_modules
diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml
index 20af6e9..4633bcf 100644
--- a/.gitea/workflows/lint.yml
+++ b/.gitea/workflows/lint.yml
@@ -50,7 +50,7 @@ jobs:
if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
-H "Content-Type: application/json" \
- -d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}"
+ -d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH}: ${RUN_URL}\"}"
deploy:
name: Deploy
diff --git a/README.md b/README.md
index ab75903..b41cd1f 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
Network monitoring dashboard for the LotusGuild Proxmox cluster.
Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`.
-**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
+**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template): shared CSS, JS, and layout patterns for all LotusGuild apps

@@ -51,11 +51,11 @@ A Proxmox/Ceph cluster lives or dies by its network, and network problems are sn
GANDALF uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
-- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
-- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
-- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, auto-refresh, fetch helpers)
-- [`web_template/python/base.html`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/base.html) — Jinja2 base template
-- [`web_template/python/auth.py`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/auth.py) — `@require_auth` decorator pattern
+- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md): full component reference, CSS variables, JS API
+- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css): unified CSS (`.lt-*` classes)
+- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js): `window.lt` utilities (toast, modal, auto-refresh, fetch helpers)
+- [`web_template/python/base.html`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/base.html): Jinja2 base template
+- [`web_template/python/auth.py`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/auth.py): `@require_auth` decorator pattern
---
@@ -82,7 +82,7 @@ Two processes share a MariaDB database:
|---|---|
| **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 |
+| **Pulse Worker** | SSH relay: runs `ethtool` + SFP DOM queries on each Proxmox host |
| **Ping** | Reachability for hosts without `node_exporter` (e.g. PBS) |
| **AT&T BGW320** (`http://192.168.1.254`) | Fiber light levels, PON and WAN state, WAN counters, scraped read-only from the gateway's status pages (no login) |
@@ -133,6 +133,12 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
patch panels get a generic panel with each port's label; anything else is a labelled plate with its
live status. Cables may use either name: checks, suggestions, tickets and the device page treat an
item and its device as the same thing.
+- **Drawn fronts**: gear without a live faceplate can pick a **Front drawing** on its cutsheet item:
+ UniFi NVR Pro (a full faceplate, `UNVRPRO` in `static/faceplates.js`, laid out from the CC0 NetBox
+ device library image), Sliger CX4712 4U (10 numbered bays), Rosewill RSV-L4500U 4U (perforated fan
+ door), the UniFi blank 24-port keystone patch panel (UACC-Rack-Panel-Patch-Blank-24: keystone jacks
+ appear where cables are documented), or a generic 1U/2U/4U server. The power light shows the linked device's live status. Case
+ drawings live in `static/fronts.js`.
- **Shelves**: an item can sit on a shelf item instead of having a rack unit. The shelf draws what is on
it side by side (mini PCs, desk switches), each with its status and a link to its page.
- **Front and rear**: each item is mounted from the front, the rear, or full depth (both sides). On a
@@ -369,7 +375,7 @@ generate tickets. Baseline is stored in MariaDB and survives daemon restarts.
| `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.
+Expired suppressions are checked at evaluation time: no background cleanup needed.
---
@@ -465,7 +471,7 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/
| `monitor.poll_interval` | Seconds between full check cycles (default: 120) |
| `monitor.failure_threshold` | Consecutive failures before creating ticket (default: 2) |
| `monitor.host_failure_threshold` | Consecutive failures before ticketing a host that has disappeared, whether it has no metrics or doesn't answer ping (default: 5). A normal reboot takes about 3-5 minutes, so the default rides it out: the host still shows as down on the dashboard, but a ticket opens only after about 8 minutes at the default poll interval. For longer maintenance, suppress the host first. |
-| `monitor.unifi_failure_threshold` | Consecutive failures before ticketing a UniFi device as offline (default: same as `failure_threshold`). Set higher than `failure_threshold` — UniFi switches/APs can take several minutes to rejoin after a firmware-update reboot, so a low threshold cuts tickets for momentary maintenance reboots instead of real failures. |
+| `monitor.unifi_failure_threshold` | Consecutive failures before ticketing a UniFi device as offline (default: same as `failure_threshold`). Set higher than `failure_threshold`: UniFi switches/APs can take several minutes to rejoin after a firmware-update reboot, so a low threshold cuts tickets for momentary maintenance reboots instead of real failures. |
| `monitor.cluster_threshold` | Hosts with failures to trigger cluster-wide P1 (default: 3) |
| `monitor.ping_hosts` | Hosts checked only by ping (no node_exporter) |
| `cutsheet.mismatch_ticket_hours` | Hours a cutsheet mismatch must last before it opens a P4 ticket (default: 24; `0` never tickets). The mismatch shows on the dashboard and the switch's page straight away, so recabling never pages anyone. Suppressing the switch or its port also stops the ticket. |
@@ -480,7 +486,7 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/
## Deployment (LXC 157)
-### 1. Database — MariaDB LXC 149 (`10.10.10.50`)
+### 1. Database: MariaDB LXC 149 (`10.10.10.50`)
```sql
CREATE DATABASE gandalf CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -494,7 +500,7 @@ Import schema:
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
@@ -674,6 +680,7 @@ gandalf/
│ ├── cutsheet.js # Cutsheet page
│ ├── events.js # Alert history page
│ ├── rack.js # Rack drawing
+│ ├── fronts.js # Drawn fronts for gear without a live faceplate (NVR, server cases)
│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics
└── templates/
├── base.html # Shared layout (header, nav, footer)
@@ -717,7 +724,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o
### Database Schema Notes
-- `cutsheet_items` gained `rack`, `shelf`, `device` and `mount` columns; they are added automatically on first use
+- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount` and `chassis` columns; they are added automatically on first use
- `cutsheet_racks`: racks saved on the rack page (name, height, notes)
- `device_links`: each device's inventory URL, by device name; created with the cutsheet tables
- `network_events`: one row per active event; `resolved_at` is set when recovered, and
@@ -740,7 +747,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o
- **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
+- **DB parameters**: all SQL uses parameterised queries via pymysql: no string
concatenation into SQL.
- **Auth**: Authelia enforces admin-only access at the NPM/LXC 167 layer; the Flask
app additionally checks the `Remote-User` and `Remote-Groups` headers via `@require_auth`.
@@ -753,13 +760,13 @@ Take LED colors from the model's quick start guide. See the comment at the top o
### Known Limitations
-- Single gunicorn worker (`--workers 1`) — required because `db.py` uses thread-local
+- 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
+- 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
+- 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).
@@ -771,11 +778,11 @@ Take LED colors from the model's quick start guide. See the comment at the top o
|---|---|---|
| `lint.yml` (python-lint) | flake8 on all `.py` files | Every push and PR |
| `lint.yml` (js-lint) | ESLint on `static/` | Every push and PR |
-| `test.yml` | pytest — `diagnose.py`, `modem.py`, and `monitor.py` alert logic | Every push and PR |
+| `test.yml` | pytest: `diagnose.py`, `modem.py`, and `monitor.py` alert logic | Every push and PR |
| `security.yml` | bandit `-ll` (medium+ severity) | Every push, PR, and weekly Monday 6am |
| `deploy` job in `lint.yml` | Calls the `gandalf-deploy` webhook on CT157 (10.10.10.61) | Push to `main` only, after both lint jobs pass |
-Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
+Branch protection is enabled on `main`: both lint jobs must pass before any PR can merge.
Tests live in `tests/`:
- `test_diagnose.py`: `DiagnosticsRunner` static methods (`build_ssh_command`, `parse_output`,
diff --git a/app.py b/app.py
index d03aa3b..e110718 100644
--- a/app.py
+++ b/app.py
@@ -1,4 +1,4 @@
-"""Gandalf – Global Advanced Network Detection And Link Facilitator.
+"""Gandalf: Global Advanced Network Detection And Link Facilitator.
Flask web application serving the monitoring dashboard and suppression
management UI. Authentication via Authelia forward-auth headers.
@@ -63,7 +63,7 @@ def inject_config():
# In-memory diagnostic job store { job_id: { status, result, created_at } }
_diag_jobs: dict = {}
_diag_lock = threading.Lock()
-# Per-user rate-limit: { username: [epoch_float, ...] } — cleaned inside _diag_lock
+# Per-user rate-limit: { username: [epoch_float, ...] }: cleaned inside _diag_lock
_diag_rate: dict = {}
@@ -80,7 +80,7 @@ def _purge_old_jobs_loop():
for jid, j in list(_diag_jobs.items()):
if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff:
j['status'] = 'done'
- j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned — no activity for 5 minutes.'}
+ j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned: no activity for 5 minutes.'}
logger.error(f'Diagnostic job {jid} stuck (no activity for 5 min); marked done/error')
@@ -201,7 +201,7 @@ def require_auth(f):
f'only auth.trusted_proxies may send them'
)
return (
- '
401 – Not authenticated '
+ '401: Not authenticated '
'Please access Gandalf through '
'auth.lotusguild.org .
',
401,
@@ -211,7 +211,7 @@ def require_auth(f):
safe_user = html.escape(user['username'])
safe_groups = html.escape(', '.join(allowed))
return (
- f'403 – Access denied '
+ f'403: Access denied '
f'Your account ({safe_user}) is not in an allowed group '
f'({safe_groups}).
',
403,
@@ -602,7 +602,7 @@ def api_create_suppression():
)
logger.info(
f'Suppression #{sup_id} created by {user["username"]}: '
- f'{target_type}/{target_name}/{target_detail} – {reason}'
+ f'{target_type}/{target_name}/{target_detail}: {reason}'
)
return jsonify({'success': True, 'id': sup_id})
@@ -814,7 +814,7 @@ def api_avatar():
# Query lldap
bind_pw = ldap_cfg.get('bind_pw', '')
if not bind_pw:
- logger.error('LDAP bind_pw not configured — avatar lookup disabled')
+ logger.error('LDAP bind_pw not configured: avatar lookup disabled')
return '', 404
avatar_data = None
@@ -832,7 +832,7 @@ def api_avatar():
if conn.entries and conn.entries[0]['avatar'].value:
avatar_data = conn.entries[0]['avatar'].value
except ImportError:
- logger.error('ldap3 not installed — run: pip install ldap3')
+ logger.error('ldap3 not installed; run pip install ldap3')
return '', 404
except Exception as e:
logger.error(f'LDAP avatar lookup failed for {username}: {e}')
@@ -942,6 +942,7 @@ def _cutsheet_view() -> dict:
data['devices'] = sorted(set(switches) | set(snapshot.get('hosts') or {})
| {u['name'] for u in snapshot.get('unifi') or [] if u.get('name')}, key=str.lower)
data['verdicts'] = cutsheet.VERDICTS
+ data['chassis'] = cutsheet.CHASSIS
data['options'] = {'kinds': cutsheet.ITEM_KINDS, 'passive_kinds': cutsheet.PASSIVE_KINDS,
'statuses': cutsheet.STATUSES, 'cable_types': cutsheet.CABLE_TYPES}
data['updated'] = link_stats.get('updated')
@@ -1174,6 +1175,7 @@ def api_rack():
entry['device'] = device
entry['linked'] = device in known
entry['mount'] = item.get('mount') or 'front'
+ entry['chassis'] = item.get('chassis') or ''
entry['through'] = item.get('through', False)
entry['url'] = url(item)
entry['status'] = _device_status(device, snapshot) if device in known else ''
@@ -1414,4 +1416,4 @@ def health():
if __name__ == '__main__':
- app.run(debug=True, host='0.0.0.0', port=5000) # nosec B201 B104 — dev runner only; production uses gunicorn
+ app.run(debug=True, host='0.0.0.0', port=5000) # nosec B201 B104: dev runner only; production uses gunicorn
diff --git a/cutsheet.py b/cutsheet.py
index b3614a2..cd9be22 100644
--- a/cutsheet.py
+++ b/cutsheet.py
@@ -26,6 +26,16 @@ ITEM_KINDS = ('patch_panel', 'wall_jack', 'device', 'shelf', 'other')
# a unit; a full-depth one (a server) takes the unit on both sides.
MOUNTS = ('front', 'rear', 'full')
RACK_UNITS_MAX = 60
+# Drawn fronts for gear without a live faceplate (keys of RACK_FRONTS in static/fronts.js)
+CHASSIS = {
+ 'unvr-pro': 'UniFi NVR Pro (UNVR Pro)',
+ 'uacc-patch-24': 'UniFi Patch Panel Blank 24 (UACC-Rack-Panel-Patch-Blank-24)',
+ 'sliger-cx4712': 'Sliger CX4712 4U (10-bay)',
+ 'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)',
+ 'server-1u': 'Generic 1U server',
+ 'server-2u': 'Generic 2U server',
+ 'server-4u': 'Generic 4U server',
+}
PASSIVE_KINDS = ('patch_panel', 'wall_jack')
SIDES = ('front', 'rear')
STATUSES = ('planned', 'installed', 'dark', 'retired')
@@ -99,9 +109,12 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
'rack': _text(data, 'rack', RACK_NAME_MAX),
'device': _text(data, 'device'),
'mount': _text(data, 'mount', 10) or 'front',
+ 'chassis': _text(data, 'chassis', 40),
'shelf': _text(data, 'shelf', NAME_MAX),
'notes': _text(data, 'notes', NOTES_MAX),
}
+ if item['chassis'] and item['chassis'] not in CHASSIS:
+ raise ValidationError(f'Unknown front drawing {item["chassis"]}')
if item['mount'] not in MOUNTS:
raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}')
if item['shelf']:
@@ -605,7 +618,7 @@ def rack_layout(items: List[dict], units: int, face: str = 'front') -> dict:
for item in sorted(racked, key=lambda i: (-(i['rack_u'] + (i.get('rack_height') or 1)), i.get('id') or 0)):
low = item['rack_u']
high = low + (item.get('rack_height') or 1) - 1
- span = f'U{low}' if high == low else f'U{low}–{high}'
+ span = f'U{low}' if high == low else f'U{low}-{high}'
if high > units:
outside.append({'name': item['name'], 'units': span})
continue
diff --git a/db.py b/db.py
index 666697c..90eed8c 100644
--- a/db.py
+++ b/db.py
@@ -678,6 +678,7 @@ _CUTSHEET_SCHEMA = (
shelf VARCHAR(100) NOT NULL DEFAULT '',
device VARCHAR(255) NOT NULL DEFAULT '',
mount VARCHAR(10) NOT NULL DEFAULT 'front',
+ chassis VARCHAR(40) NOT NULL DEFAULT '',
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -768,7 +769,7 @@ def set_device_link(name: str, inventory_url: str, user: str) -> None:
)
_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'rack', 'shelf', 'device',
- 'mount', 'notes')
+ 'mount', 'chassis', 'notes')
# Columns added to cutsheet_items after its first release: (name, definition)
_ITEM_COLUMNS_ADDED = (
('rack', "VARCHAR(100) NOT NULL DEFAULT ''"),
@@ -776,6 +777,7 @@ _ITEM_COLUMNS_ADDED = (
# The Gandalf device (UniFi or host name) an item is, when its own name differs
('device', "VARCHAR(255) NOT NULL DEFAULT ''"),
('mount', "VARCHAR(10) NOT NULL DEFAULT 'front'"),
+ ('chassis', "VARCHAR(40) NOT NULL DEFAULT ''"),
)
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
diff --git a/diagnose.py b/diagnose.py
index 594bfdb..c8d6719 100644
--- a/diagnose.py
+++ b/diagnose.py
@@ -1,4 +1,4 @@
-"""Gandalf – Link Diagnostics module.
+"""Gandalf: Link Diagnostics module.
Runs a comprehensive SSH-based diagnostic against a server NIC and
analyses the result against switch port data to surface root causes.
@@ -475,7 +475,7 @@ class DiagnosticsRunner:
# Physical carrier
if carrier == '0':
add(issues, 'NO_CARRIER',
- 'No physical carrier — cable/SFP disconnected or switch port disabled')
+ 'No physical carrier: cable/SFP disconnected or switch port disabled')
elif eth.get('link_detected') is False and carrier != '0':
add(issues, 'LINK_NOT_DETECTED',
'NIC does not detect link signal despite carrier sysfs reading non-zero')
@@ -483,7 +483,7 @@ class DiagnosticsRunner:
# Duplex
if eth.get('duplex') == 'half':
add(issues, 'HALF_DUPLEX',
- 'Half-duplex detected — likely duplex mismatch; force full-duplex on both ends')
+ 'Half-duplex detected: likely duplex mismatch; force full-duplex on both ends')
# Speed mismatch (switch vs server NIC)
sw_speed = switch_port_data.get('speed_mbps', 0) or 0
@@ -498,31 +498,31 @@ class DiagnosticsRunner:
if rx_dbm is not None:
if rx_dbm < -25:
add(issues, 'SFP_RX_CRITICAL',
- f'RX power critically low ({rx_dbm:.2f} dBm) — fiber not connected or SFP failed')
+ f'RX power critically low ({rx_dbm:.2f} dBm): fiber not connected or SFP failed')
elif rx_dbm < -18:
add(warnings, 'SFP_RX_LOW',
- f'RX power low ({rx_dbm:.2f} dBm) — check fiber cleanliness and SFP seating')
+ f'RX power low ({rx_dbm:.2f} dBm): check fiber cleanliness and SFP seating')
if tx_dbm is not None and tx_dbm < -10:
add(warnings, 'SFP_TX_LOW',
- f'TX power low ({tx_dbm:.2f} dBm) — SFP may be failing or requires cleaning')
+ f'TX power low ({tx_dbm:.2f} dBm): SFP may be failing or requires cleaning')
# Carrier changes (flapping)
if cc is not None:
if cc > 100:
add(issues, 'CARRIER_FLAPPING',
- f'Link has flapped {cc} times — severe physical instability')
+ f'Link has flapped {cc} times: severe physical instability')
elif cc > 20:
add(warnings, 'CARRIER_FLAPS',
- f'Link has flapped {cc} times — intermittent physical issue')
+ f'Link has flapped {cc} times: intermittent physical issue')
# CRC errors
crc = sysfs.get('rx_crc_errors', 0) or 0
if crc > 100:
add(issues, 'CRC_ERRORS_HIGH',
- f'High CRC error count ({crc}) — dirty fiber/connector or cable damage')
+ f'High CRC error count ({crc}): dirty fiber/connector or cable damage')
elif crc > 10:
add(warnings, 'CRC_ERRORS_LOW',
- f'CRC errors present ({crc}) — cable or SFP quality issue')
+ f'CRC errors present ({crc}): cable or SFP quality issue')
# Kernel events
err_events = [e for e in dmesg if e['severity'] == 'error']
@@ -540,9 +540,9 @@ class DiagnosticsRunner:
if expected_switch and srv_neighbor and names_differ:
add(warnings, 'LLDP_MISMATCH',
f'LLDP mismatch: this port is on "{switch_port_data.get("switch_name")}" but the server\'s '
- f'lldpctl sees "{lldp.get("neighbor_system")}" — cross-cabled port?')
+ f'lldpctl sees "{lldp.get("neighbor_system")}": cross-cabled port?')
else:
add(info, 'LLDP_MISSING',
- 'lldpd not running on server — install lldpd for full path validation')
+ 'lldpd not running on server: install lldpd for full path validation')
return {'issues': issues, 'warnings': warnings, 'info': info}
diff --git a/monitor.py b/monitor.py
index cf46eb5..6894ece 100644
--- a/monitor.py
+++ b/monitor.py
@@ -427,7 +427,7 @@ class TicketClient:
def create(self, title: str, description: str, priority: str = '2') -> Optional[str]:
if not self.api_key or not self.url:
- logger.warning('Ticket API not configured – skipping ticket creation')
+ logger.warning('Ticket API not configured: skipping ticket creation')
return None
try:
resp = requests.post(
@@ -453,7 +453,7 @@ class TicketClient:
logger.info(f'Created ticket #{tid}: {title}')
return tid
if data.get('existing_ticket_id'):
- logger.info(f'Duplicate suppressed by API – existing #{data["existing_ticket_id"]}')
+ logger.info(f'Duplicate suppressed by API: existing #{data["existing_ticket_id"]}')
return data['existing_ticket_id']
logger.warning(f'Unexpected ticket API response: {data}')
except Exception as e:
@@ -814,7 +814,7 @@ class LinkStatsCollector:
host = instance_map.get(instance, host_ip)
ifaces = list(iface_metrics.keys())
- # SSH ethtool collection via Pulse worker — only for explicitly configured
+ # SSH ethtool collection via Pulse worker: only for explicitly configured
# hosts (instance_map keys). Hosts like postgresql/matrix may report
# node_exporter metrics to Prometheus but don't need link diagnostics.
ethtool_data: Dict[str, dict] = {}
@@ -1213,11 +1213,11 @@ class NetworkMonitor:
db.resolve_event('interface_down', host, iface)
else:
if prev is None:
- # First observation is down – could be unused port, don't alert
+ # First observation is down: could be unused port, don't alert
new_baseline[host][iface] = 'initial_down'
elif prev == 'initial_down':
- # Persistently down since first observation – no alert
+ # Persistently down since first observation: no alert
pass
else: # prev == 'up'
@@ -1256,7 +1256,7 @@ class NetworkMonitor:
db.set_baseline(new_baseline)
- # Cluster-wide check – only genuine regressions count
+ # Cluster-wide check: only genuine regressions count
if len(hosts_with_regression) >= self.cluster_thresh:
sup = db.check_suppressed(suppressions, 'all', '')
event_id, is_new, consec, ticket_id = db.upsert_event(
@@ -1824,7 +1824,7 @@ class NetworkMonitor:
# ------------------------------------------------------------------
def run(self) -> None:
logger.info(
- f'Gandalf monitor started – poll_interval={self.poll_interval}s '
+ f'Gandalf monitor started: poll_interval={self.poll_interval}s '
f'fail_thresh={self.fail_thresh} host_fail_thresh={self.host_fail_thresh} '
f'unifi_fail_thresh={self.unifi_fail_thresh}'
)
@@ -1832,13 +1832,13 @@ class NetworkMonitor:
try:
logger.info('Starting network check cycle')
- # 1. Fetch interface states once — shared by snapshot and alert processing
+ # 1. Fetch interface states once: shared by snapshot and alert processing
iface_states = self.prom.get_interface_states()
- # 2. Fetch UniFi devices once — used by both snapshot and alert processing
+ # 2. Fetch UniFi devices once: used by both snapshot and alert processing
unifi_devices = self.unifi.get_devices()
- # 3a. Ping-only hosts once — shared by snapshot and alert processing
+ # 3a. Ping-only hosts once: shared by snapshot and alert processing
ping_states: Dict[str, Optional[bool]] = {
h['name']: self.pulse.ping(h['ip'])
for h in self.cfg.get('monitor', {}).get('ping_hosts', [])
diff --git a/schema.sql b/schema.sql
index fbfc6cf..0b6a8bd 100644
--- a/schema.sql
+++ b/schema.sql
@@ -1,4 +1,4 @@
--- Gandalf Network Monitor – Database Schema
+-- Gandalf Network Monitor: Database Schema
-- Run on MariaDB LXC 149 (10.10.10.50)
CREATE DATABASE IF NOT EXISTS gandalf
diff --git a/static/app.js b/static/app.js
index 53b8da0..b586c4f 100644
--- a/static/app.js
+++ b/static/app.js
@@ -11,13 +11,13 @@
const resp = await _fetch(...args);
if (resp.status === 401) {
window.location.reload();
- throw new Error('Session expired — reloading');
+ throw new Error('Session expired, reloading');
}
return resp;
};
})();
-// ── Toast notifications — thin wrapper over lt.toast ──────────────────
+// ── Toast notifications: thin wrapper over lt.toast ──────────────────
function showToast(msg, type = 'success') {
if (type === 'error') return lt.toast.error(msg);
if (type === 'warning') return lt.toast.warning(msg);
@@ -116,7 +116,7 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
}
const mins = Math.floor(checkAge / 60);
staleBanner.querySelector('.lt-alert-msg').textContent =
- `Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
+ `Monitoring data is stale: last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
staleBanner.style.display = '';
} else if (staleBanner) {
staleBanner.style.display = 'none';
@@ -275,7 +275,7 @@ async function refreshModem() {
}
function _modemUptime(seconds) {
- if (seconds === null || seconds === undefined) return '–';
+ if (seconds === null || seconds === undefined) return '-';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
@@ -283,7 +283,7 @@ function _modemUptime(seconds) {
}
function _modemRate(bytesPerSec) {
- if (bytesPerSec === null || bytesPerSec === undefined) return '–';
+ if (bytesPerSec === null || bytesPerSec === undefined) return '-';
const mbps = bytesPerSec * 8 / 1e6;
return mbps >= 1000 ? `${(mbps / 1000).toFixed(2)} Gbps` : `${mbps.toFixed(1)} Mbps`;
}
@@ -298,7 +298,7 @@ function _modemStat(label, value, cls, note) {
}
function _modemOptic(label, metric, status, unit) {
- if (!metric || metric.value === null || metric.value === undefined) return _modemStat(label, '–');
+ if (!metric || metric.value === null || metric.value === undefined) return _modemStat(label, '-');
const cls = { alarm: 'lt-text-red', warning: 'lt-text-amber', ok: 'lt-text-green' }[status] || '';
return _modemStat(label, `${metric.value} ${unit}`, cls, `warn ${metric.warn_low} to ${metric.warn_high}`);
}
@@ -391,7 +391,7 @@ function updateModem(data) {
if (topo) topo.classList.add('topo-v2-status-down');
body.innerHTML = `
Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
- Last attempt ${lt.escHtml(data.updated || '–')}
+ Last attempt ${lt.escHtml(data.updated || '-')}
${_modemChart(data.history)}`;
return;
}
@@ -404,7 +404,7 @@ function updateModem(data) {
const rx = optics.rx_power ? optics.rx_power.value : null;
if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0);
- if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx –' : `Rx ${rx} dBm`;
+ if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx -' : `Rx ${rx} dBm`;
if (title) {
title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`]
.filter(Boolean).join(' · ');
@@ -417,7 +417,7 @@ function updateModem(data) {
${_modemStat('WAN', problems.length ? 'DOWN' : 'UP', problems.length ? 'lt-text-red' : 'lt-text-green',
data.wan_ip || '')}
- ${_modemStat('PON', data.pon_state || '–', '', data.wavelength || '')}
+ ${_modemStat('PON', data.pon_state || '-', '', data.wavelength || '')}
${_modemOptic('Rx power', optics.rx_power, status.rx_power, 'dBm')}
${_modemOptic('Tx power', optics.tx_power, status.tx_power, 'dBm')}
${_modemOptic('Optics temp', optics.temperature, status.temperature, '°C')}
@@ -427,7 +427,7 @@ function updateModem(data) {
${_modemChart(data.history)}
${_modemOutages(data.wan_history)}
- Updated ${lt.escHtml(data.updated || '–')}
`;
+ Updated ${lt.escHtml(data.updated || '-')}
`;
}
function updateUnifiTable(devices) {
@@ -481,7 +481,7 @@ function updateEventsTable(events, totalActive) {
const ticket = e.ticket_id
? `#${lt.escHtml(String(e.ticket_id))} `
- : '–';
+ : '-';
const supBadge = e.is_suppressed
? `🔕 sup `
: '';
@@ -490,7 +490,7 @@ function updateEventsTable(events, totalActive) {
${e.severity} ${supBadge}
${lt.escHtml(e.event_type.replace(/_/g,' '))}
${lt.escHtml(e.target_name)}
- ${lt.escHtml(e.target_detail || '–')}
+ ${lt.escHtml(e.target_detail || '-')}
${lt.escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}
${lt.time.ago(_toIso(e.first_seen))}
${lt.time.ago(_toIso(e.last_seen))}
@@ -618,7 +618,7 @@ async function submitSuppress(e) {
}
}
-// ── Suppress form – wired here so the modal works from any page ──────
+// ── Suppress form: wired here so the modal works from any page ──────
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
diff --git a/static/base.js b/static/base.js
index cd20236..943e257 100644
--- a/static/base.js
+++ b/static/base.js
@@ -347,7 +347,7 @@
'[ OK ] Scheduled tasks registered',
'[ OK ] Terminal interface rendered',
'',
- '> ALL SYSTEMS NOMINAL — ' + name,
+ '> ALL SYSTEMS NOMINAL: ' + name,
'',
];
@@ -486,7 +486,7 @@
---------------------------------------------------------------- */
function timeAgo(value) {
const date = value instanceof Date ? value : new Date(value);
- if (isNaN(date)) return '—';
+ if (isNaN(date)) return '-';
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
@@ -505,7 +505,7 @@
function formatDate(value) {
const date = value instanceof Date ? value : new Date(value);
- if (isNaN(date)) return '—';
+ if (isNaN(date)) return '-';
try {
return date.toLocaleString(undefined, {
timeZone: global.APP_TIMEZONE || undefined,
@@ -522,7 +522,7 @@
lt.bytes.format(n)
---------------------------------------------------------------- */
function formatBytes(bytes) {
- if (bytes == null) return '—';
+ if (bytes == null) return '-';
const units = ['B','KB','MB','GB','TB']; let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
@@ -1137,9 +1137,9 @@
lt.num.format / compact / percent / pad / clamp / lerp
---------------------------------------------------------------- */
const num = {
- format(n, d) { if (n == null || isNaN(n)) return '—'; return Number(n).toLocaleString(undefined, { minimumFractionDigits: d || 0, maximumFractionDigits: d != null ? d : 2 }); },
+ format(n, d) { if (n == null || isNaN(n)) return '-'; return Number(n).toLocaleString(undefined, { minimumFractionDigits: d || 0, maximumFractionDigits: d != null ? d : 2 }); },
compact(n) { if (Math.abs(n) >= 1e9) return (n/1e9).toFixed(1)+'B'; if (Math.abs(n) >= 1e6) return (n/1e6).toFixed(1)+'M'; if (Math.abs(n) >= 1e3) return (n/1e3).toFixed(1)+'K'; return String(n); },
- percent(n, total) { return total ? ((n/total)*100).toFixed(1)+'%' : '—'; },
+ percent(n, total) { return total ? ((n/total)*100).toFixed(1)+'%' : '-'; },
pad(n, w) { return String(Math.floor(n)).padStart(w, '0'); },
clamp(n, a, b) { return Math.min(b, Math.max(a, n)); },
lerp(a, b, t) { return a + (b - a) * t; },
@@ -1759,7 +1759,7 @@
_offlineBanner.className = 'lt-offline-banner';
_offlineBanner.setAttribute('role', 'alert');
_offlineBanner.setAttribute('aria-live', 'assertive');
- _offlineBanner.innerHTML = ' NO NETWORK CONNECTION — RECONNECTING...';
+ _offlineBanner.innerHTML = ' NO NETWORK CONNECTION, RECONNECTING...';
document.body.appendChild(_offlineBanner);
}
}
@@ -1770,7 +1770,7 @@
window.addEventListener('offline', () => {
document.body.classList.add('lt-is-offline');
_offlineGetBanner().classList.add('is-visible');
- toast.warning('Connection lost — working offline');
+ toast.warning('Connection lost, working offline');
_offlineHandlers.forEach(fn => fn());
});
window.addEventListener('online', () => {
diff --git a/static/cutsheet.js b/static/cutsheet.js
index dac4bac..db73a57 100644
--- a/static/cutsheet.js
+++ b/static/cutsheet.js
@@ -112,10 +112,10 @@ function renderCables() {
ID From To Type Status Check ${CS_CAN_EDIT ? ' ' : ''}
${rows.map(c => `
- ${c.label ? esc(c.label) : '– '}${c.label && !c.label_applied && c.status !== 'retired' ? ' not stuck on ' : ''}
+ ${c.label ? esc(c.label) : '- '}${c.label && !c.label_applied && c.status !== 'retired' ? ' not stuck on ' : ''}
${endText(c, 'a')}
${endText(c, 'b')}${pathText(c) ? `${pathText(c)}
` : ''}
- ${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '–'}
+ ${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '-'}
${esc(c.status)}
${verdictBadge(c)}${esc(c.detail)}
${CS_CAN_EDIT ? `Edit ` : ''}
@@ -180,11 +180,11 @@ function renderPanels() {
const lab = labels[`${p.id}|${n}`];
const cell = (c, side) => c
? `${esc(c.label || 'unlabeled')} → ${farEnd(c, p.name, n, side)}`
- : '– ';
+ : '- ';
rows.push(`
${n}
- ${lab ? esc(lab.label) + (lab.applied ? '' : ' not stuck on ') : '– '}
+ ${lab ? esc(lab.label) + (lab.applied ? '' : ' not stuck on ') : '- '}
${CS_CAN_EDIT ? `edit ` : ''}
${cell(front, 'front')}
${cell(rear, 'rear')}
@@ -249,7 +249,7 @@ function renderRack() {
const top = i.rack_u + (i.rack_height || 1) - 1;
return `
- ${i.rack_height > 1 ? `U${esc(top)}–${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}
+ ${i.rack_height > 1 ? `U${esc(top)}-${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}
${deviceLink(i.name)}
${esc(KIND_TEXT[i.kind] || i.kind)}
`;
@@ -259,7 +259,7 @@ function renderRack() {
function rackText(i) {
if (i.shelf) return `on ${esc(i.shelf)}`;
- if (!i.rack_u) return '–';
+ if (!i.rack_u) return '-';
const side = { rear: ' rear', full: ' full depth' }[i.mount] || '';
const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}${side}`;
return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where;
@@ -282,9 +282,9 @@ function renderItems() {
${deviceLink(i.name)}
${esc(KIND_TEXT[i.kind] || i.kind)}
- ${esc(i.port_count || '–')}
+ ${esc(i.port_count || '-')}
${rackText(i)}
- ${esc(i.location || '–')}
+ ${esc(i.location || '-')}
${esc(i.notes || '')}
${CS_CAN_EDIT ? `Edit ` : ''}
`).join('')}
@@ -370,7 +370,7 @@ function openCable(cable, prefill) {
const form = document.getElementById('cs-cable-form');
form.reset();
const types = ['', ..._cs.options.cable_types];
- form.cable_type.innerHTML = types.map(t => `${esc(t || '–')} `).join('');
+ form.cable_type.innerHTML = types.map(t => `${esc(t || '-')} `).join('');
const data = Object.assign({ status: 'installed', label: cable ? '' : _cs.next_label }, cable || {}, prefill || {});
for (const key of ['id', 'label', 'a_device', 'a_port', 'b_device', 'b_port', 'cable_type', 'length_m', 'color', 'status', 'notes']) {
if (form[key]) form[key].value = data[key] == null ? '' : data[key];
@@ -392,11 +392,13 @@ function openItem(item) {
const data = Object.assign({ kind: 'patch_panel', mount: 'front' }, item || {});
document.getElementById('cs-rack-list').innerHTML = (_cs.racks || []).map(r => ``).join('');
document.getElementById('cs-known-devices').innerHTML = (_cs.devices || []).map(d => ` `).join('');
+ form.chassis.innerHTML = ' Automatic (faceplate or plain plate) ' +
+ Object.entries(_cs.chassis || {}).map(([k, v]) => `${esc(v)} `).join('');
const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id));
form.shelf.innerHTML = 'Not on a shelf ' +
shelves.map(i => `${esc(i.name)} `).join('');
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
- 'location', 'notes']) {
+ 'chassis', 'location', 'notes']) {
form[key].value = data[key] == null ? '' : data[key];
}
if (!item) form.port_count.value = 24;
@@ -450,7 +452,7 @@ async function saveItem(e) {
e.preventDefault();
const form = e.target;
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
- 'location', 'notes']);
+ 'chassis', 'location', 'notes']);
const id = form.id.value;
await save(() => id ? lt.api.put(`/api/cutsheet/items/${encodeURIComponent(id)}`, body)
: lt.api.post('/api/cutsheet/items', body), 'Saved', 'cs-item-modal');
diff --git a/static/device.js b/static/device.js
index 4e95d42..78dd75f 100644
--- a/static/device.js
+++ b/static/device.js
@@ -13,16 +13,16 @@ let _device = null;
// ── Formatting ───────────────────────────────────────────────────────
function fmtSpeed(mbps) {
- if (!mbps) return '–';
+ if (!mbps) return '-';
return mbps >= 1000 ? (mbps / 1000).toFixed(0) + ' Gbps' : mbps + ' Mbps';
}
function fmtUnit(value, unit, digits) {
- return value === null || value === undefined ? '–' : `${value.toFixed(digits)} ${unit}`;
+ return value === null || value === undefined ? '-' : `${value.toFixed(digits)} ${unit}`;
}
function fmtUptime(seconds) {
- if (seconds === null || seconds === undefined) return '–';
+ if (seconds === null || seconds === undefined) return '-';
const d = Math.floor(seconds / 86400), h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return d ? `${d}d ${h}h` : `${h}h ${m}m`;
@@ -108,7 +108,7 @@ function sfpPanel(s) {
${vendor}
${item('Temp', tempClass(s.temp_c), fmtUnit(s.temp_c, '°C', 1), 'Module temperature. Normal: below 70°C.')}
- ${item('Voltage', voltClass(s.voltage_v), fmtUnit(s.voltage_v, 'V', 2), 'Supply voltage. Normal: 3.1–3.5 V.')}
+ ${item('Voltage', voltClass(s.voltage_v), fmtUnit(s.voltage_v, 'V', 2), 'Supply voltage. Normal: 3.1-3.5 V.')}
${item('Bias', '', fmtUnit(s.bias_ma, 'mA', 2), 'Laser bias current. High values may mean an aging laser.')}
${item('TX Power', tx, fmtUnit(s.tx_power_dbm, 'dBm', 2), 'Optical transmit power.', txPct)}
${item('RX Power', rx, fmtUnit(s.rx_power_dbm, 'dBm', 2), 'Optical receive power. Very low values may mean dirty or damaged fiber.', rxPct)}
@@ -127,9 +127,9 @@ function hostRows(interfaces) {
id: name, label: name, up, data: d, sub: d.port_type || '',
detail: `
- ${stat('Duplex', esc(d.duplex || '–'), 'Full = both directions at once. Half can cause collisions.')}
- ${stat('Auto-neg', d.auto_neg === null || d.auto_neg === undefined ? '–' : d.auto_neg ? 'On' : 'Off')}
- ${stat('Carrier Δ', d.carrier_changes === null || d.carrier_changes === undefined ? '–' : esc(d.carrier_changes), 'Times the link went up or down. High values mean flapping.')}
+ ${stat('Duplex', esc(d.duplex || '-'), 'Full = both directions at once. Half can cause collisions.')}
+ ${stat('Auto-neg', d.auto_neg === null || d.auto_neg === undefined ? '-' : d.auto_neg ? 'On' : 'Off')}
+ ${stat('Carrier Δ', d.carrier_changes === null || d.carrier_changes === undefined ? '-' : esc(d.carrier_changes), 'Times the link went up or down. High values mean flapping.')}
${stat('TX Err/s', fmtErrors(d.tx_errs_rate))}
${stat('RX Err/s', fmtErrors(d.rx_errs_rate))}
${stat('TX Drop/s', fmtErrors(d.tx_drops_rate))}
@@ -171,13 +171,13 @@ function renderTable(rows) {
const d = r.data;
const neighbor = r.neighbor
? (r.neighbor.name ? `
${esc(r.neighbor.name)} ` : esc(r.neighbor.chassis || r.neighbor.port || ''))
- : '–';
+ : '-';
return `
${open ? '▾' : '▸'}
${r.num !== undefined ? `#${esc(r.num)} ` : ''}${esc(r.label)}${esc(r.sub)}
- ${r.up ? fmtSpeed(d.speed_mbps) : '–'}
+ ${r.up ? fmtSpeed(d.speed_mbps) : '-'}
${fmtRate(d.rx_bytes_rate)}
${fmtRate(d.tx_bytes_rate)}
${isPorts ? `${neighbor} ` : ''}
@@ -241,7 +241,7 @@ function renderUplinks(d) {
const rows = d.uplinks.map(u => `
- ${esc(u.host_interface || '–')}
+ ${esc(u.host_interface || '-')}
${esc(u.switch)}
#${esc(u.port_idx)} ${esc(u.port)}
${fmtSpeed(u.speed_mbps)}
@@ -259,8 +259,8 @@ function renderWan(d) {
const rows = names.map(n => {
const w = wan[n];
return ` ${esc(n.toUpperCase())}
- ${fmtSpeed(w.speed_mbps)} ${w.latency_ms === null || w.latency_ms === undefined ? '–' : `${esc(w.latency_ms)} ms`}
- ${w.availability === null || w.availability === undefined ? '–' : `${esc(w.availability)}%`} `;
+
${fmtSpeed(w.speed_mbps)} ${w.latency_ms === null || w.latency_ms === undefined ? '-' : `${esc(w.latency_ms)} ms`}
+
${w.availability === null || w.availability === undefined ? '-' : `${esc(w.availability)}%`} `;
}).join('');
return section('WAN', `
WAN connections
@@ -275,8 +275,8 @@ function renderOutlets(d) {
#${esc(o.index)}
- ${esc(o.name || '–')}
- ${o.power_w === null || o.power_w === undefined ? '–' : `${o.power_w.toFixed(1)} W`}
+ ${esc(o.name || '-')}
+ ${o.power_w === null || o.power_w === undefined ? '-' : `${o.power_w.toFixed(1)} W`}
${o.auto_power_cycle ? 'auto power-cycle ' : ''}
`).join('');
return section('Outlets', `
@@ -295,7 +295,7 @@ function renderEvents(events) {
${esc(e.severity)} ${e.is_suppressed && !e.resolved_at ? '🔕 sup ' : ''}
${esc(e.event_type.replace(/_/g, ' '))}
- ${esc(e.target_detail || '–')}
+ ${esc(e.target_detail || '-')}
${esc(e.description || '')}
${esc(lt.time.ago(_toIso(e.first_seen)))}
${e.resolved_at ? `resolved ${esc(lt.time.ago(_toIso(e.resolved_at)))}` : 'active '}
diff --git a/static/events.js b/static/events.js
index aaae2e0..c51a6e4 100644
--- a/static/events.js
+++ b/static/events.js
@@ -28,7 +28,7 @@ function duration(seconds) {
}
function when(iso) {
- if (!iso) return '–';
+ if (!iso) return '-';
const d = new Date(_toIso(iso));
if (isNaN(d)) return esc(iso);
const text = d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
@@ -36,7 +36,7 @@ function when(iso) {
}
function ticketLink(id) {
- if (!id) return '– ';
+ if (!id) return '- ';
const base = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url) || '';
return base ? `#${esc(id)} ` : `#${esc(id)}`;
}
@@ -157,7 +157,7 @@ function renderTable(data) {
const next = end < data.total;
document.getElementById('ev-pager').innerHTML = (prev || next) ? `
← Newer
- ${data.offset + 1}–${end} of ${data.total}
+ ${data.offset + 1}-${end} of ${data.total}
Older → ` : '';
}
diff --git a/static/faceplates.js b/static/faceplates.js
index 4974235..f2cab7c 100644
--- a/static/faceplates.js
+++ b/static/faceplates.js
@@ -225,7 +225,21 @@ function fpQuadScreen(x, y, s) {
`;
}
-const fpBrand = (x, y, text) => `${text} `;
+// The model name printed under a UniFi screen: centred on the screen, first word bold and
+// second word light ("USW Pro", "NVR Pro"), as on the real units. `bolt` adds the PoE mark.
+function fpModel(cx, y, bold, light = '', { size = 7.5, bolt = false } = {}) {
+ const chars = bold.length + (light ? light.length + 1 : 0);
+ const boltW = bolt ? size * 1.3 : 0;
+ const textW = chars * size * 0.6;
+ const x = cx - boltW / 2;
+ const mark = bolt
+ ? `
+
+ `
+ : '';
+ return `${bold}${light
+ ? `${light} ` : ''} ${mark}`;
+}
// Two rows of jacks, odd numbers on top: how UniFi rack switches number ports
function rj45Pairs(columns, rows) {
@@ -270,7 +284,7 @@ const FACEPLATES = {
- ${fpBrand(16, 94, 'Dream Machine Pro')}
+ ${fpModel(43, 94, 'UDM', 'Pro')}
@@ -284,6 +298,35 @@ const FACEPLATES = {
},
},
+ // UniFi NVR Pro, 2U. Layout from the CC0 NetBox devicetype-library front image
+ // (elevation-images/Ubiquiti/ubiquiti-unifi-protect-network-video-recorder-pro.front.png):
+ // touchscreen top left, bays 1-3 on top and 4-7 below, RJ45 (1) and SFP+ (2) top right.
+ // UniFi Network doesn't list a Protect NVR, so on the rack it is picked by Front drawing;
+ // if it is ever reported as UNVRPRO its ports go live like any other.
+ UNVRPRO: {
+ width: 1167, height: 230, frameClass: 'fp-scroll', scrollX: 700,
+ label: 'UniFi NVR Pro',
+ legend: [...LEGEND_RJ45, ['white', '10G']],
+ roles: { 1: 'RJ45 1G', 2: 'SFP+ 10G' },
+ ports: [
+ { idx: 1, type: 'rj45', x: 1040, y: 74, scale: 1.3, label: [1040, 44] },
+ { idx: 2, type: 'sfp', x: 1072, y: 61, w: 40, h: 27, led: [1092, 96], label: [1092, 44] },
+ ],
+ decor() {
+ const spans = [[97, 293], [302, 498], [507, 703], [712, 908], [916, 1112]];
+ const bay = (x, y, n) => `
+
+
+ ${n} `;
+ return `${fpVents(spans, 8, 4)}${fpVents(spans, 216, 4)}
+ ${fpQuadScreen(14, 33, 62)}${fpModel(45, 111, 'NVR', 'Pro')}
+ ${[97, 384, 669].map((x, i) => bay(x, 27, i + 1)).join('')}
+ ${[12, 299, 585, 871].map((x, i) => bay(x, 125, i + 4)).join('')}
+
+ ${fpReset(1124, 219)}`;
+ },
+ },
+
// Port layout and LED colors: https://dl.ui.com/qsg/USW-Pro-24/USW-Pro-24_EN.html
// (the non-PoE sibling). PoE draw has no documented LED, so it is a bolt by the number.
US24PRO: {
@@ -299,7 +342,7 @@ const FACEPLATES = {
],
decor() {
return `${fpVents([[98, 298], [304, 504], [512, 710], [717, 917], [923, 1122]], 3)}
- ${fpQuadScreen(15, 23, 62)}${fpBrand(17, 99, 'USW Pro')}
+ ${fpQuadScreen(15, 23, 62)}${fpModel(46, 99, 'USW', 'Pro', { bolt: true })}
@@ -321,7 +364,7 @@ const FACEPLATES = {
const dividers = [876.4, 925.6, 975.6, 1025]
.map(x => ` `).join('');
return `${fpVents([[91, 280], [287, 476], [482, 671], [678, 867], [874, 1062]], 7)}
- ${fpQuadScreen(12, 25, 58)}${fpBrand(28, 96, 'USW')}
+ ${fpQuadScreen(12, 25, 58)}${fpModel(41, 96, 'USW')}
${dividers}
${fpReset(1078.6, 95)}`;
@@ -396,7 +439,7 @@ const FACEPLATES = {
`;
return `${ear(0)}${ear(1126)}
- ${fpQuadScreen(61, 132, 56)}${fpBrand(68, 199, 'USP PDU Pro')}
+ ${fpQuadScreen(61, 132, 56)}${fpModel(89, 199, 'USP', 'PDU Pro')}
diff --git a/static/fronts.js b/static/fronts.js
new file mode 100644
index 0000000..e8af622
--- /dev/null
+++ b/static/fronts.js
@@ -0,0 +1,148 @@
+'use strict';
+/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */
+// Drawn fronts for rack gear Gandalf can't read a faceplate from: custom-built servers. Chosen per cutsheet item ("Front drawing"). Each one
+// shows the device's live status on its power light when the item is linked to a device.
+//
+// Spec: { label, units, width, height, draw(ctx) -> SVG markup }, ctx: { status, name }.
+// Width:height should be about 10.5:1 per rack unit so the drawing fills its slot.
+
+const FRONT_STATUS = { up: '#2fd36b', degraded: '#ffb300', down: '#e0312b' };
+
+function frontLed(status) {
+ return FRONT_STATUS[status] || '#6b737d';
+}
+
+function ears(w, h, cls = 'rf-ear') {
+ // Rack ears with their mounting slots, both sides
+ const slots = Array.from({ length: Math.round(h / 105) }, (_, i) => 105 * i + 52);
+ const holes = x => slots.map(y => ` `).join('');
+ return ` ${holes(12)}
+ ${holes(w - 21)}`;
+}
+
+function vents(x0, x1, y, h = 8, gap = 6, seg = 120) {
+ const out = [];
+ for (let x = x0; x + seg <= x1; x += seg + gap) out.push(` `);
+ return out.join('');
+}
+
+function powerButton(cx, cy, r, status) {
+ return `
+ `;
+}
+
+function usbA(x, y) {
+ return ` `;
+}
+
+function usbC(x, y) {
+ return ` `;
+}
+
+function keyLock(cx, cy) {
+ return ` `;
+}
+
+function brand(x, y, text, anchor = 'start') {
+ return `${text} `;
+}
+
+function driveBay(x, y, w, h, vertical) {
+ // A hot-swap bay: tray face, latch and activity light
+ const latch = vertical
+ ? ` `
+ : ` `;
+ const led = vertical ? ` `
+ : ` `;
+ return ` ${latch}${led}`;
+}
+
+const RACK_FRONTS = {
+ // Sliger CX4712 4U with its faceplate off (as driveAtlas draws it): two 5.25" blanks side by
+ // side, ten 3.5" hot-swap bays standing on edge in one row, a narrow I/O strip on the right.
+ 'sliger-cx4712': {
+ label: 'Sliger CX4712 4U (10-bay)', units: 4, width: 1100, height: 420,
+ draw({ status }) {
+ // Bays numbered left to right, the local convention driveAtlas uses for this case
+ const bays = Array.from({ length: 10 }, (_, i) => `${driveBay(78 + i * 86, 150, 78, 228, true)}
+ ${i + 1} `).join('');
+ return ` ${ears(1100, 420, 'rf-ear-dark')}
+
+
+ ${vents(98, 480, 74, 7, 8, 40)}${vents(530, 912, 74, 7, 8, 40)}
+ ${bays}
+
+ ${powerButton(1002, 78, 20, status)}
+ ${usbA(991, 130)}${usbA(991, 150)}${usbC(995, 176)}
+ ${keyLock(1002, 220)}
+ ${brand(1002, 372, 'SLIGER', 'middle')}`;
+ },
+ },
+
+ // Rosewill RSV-L4500U 4U: the perforated, lockable drop-down door over the fan wall
+ // (15 bays sit behind it), D-handles on the ears, I/O strip with power and USB on the right.
+ 'rosewill-rsv-l4500u': {
+ label: 'Rosewill RSV-L4500U 4U (15-bay)', units: 4, width: 1100, height: 420,
+ draw({ status }) {
+ const fan = cx => `
+
+ ${[0, 72, 144, 216, 288].map(a => ` `).join('')}`;
+ const handle = x => ` `;
+ return ` ${ears(1100, 420, 'rf-ear-dark')}
+ ${handle(36)}${handle(1048)}
+
+
+
+ ${fan(225)}${fan(504)}${fan(783)}
+
+
+
+ ${powerButton(997, 62, 18, status)}
+
+
+ ${usbA(986, 160)}${usbA(986, 180)}
+ ${keyLock(997, 232)}
+ ${brand(997, 388, 'ROSEWILL', 'middle')}`;
+ },
+ },
+
+ // Generic servers, for gear without a specific drawing
+ 'server-1u': genericServer(1, 4),
+ 'server-2u': genericServer(2, 12),
+ 'server-4u': genericServer(4, 24),
+};
+
+function genericServer(units, bays) {
+ const H = 105 * units;
+ return {
+ label: `Generic ${units}U server`, units, width: 1100, height: H,
+ draw({ status }) {
+ const rows = Math.max(1, Math.round(units * 0.75));
+ const cols = Math.ceil(bays / rows);
+ const bw = Math.min(118, 720 / cols - 8);
+ const bh = (H - 30 - (rows - 1) * 8) / rows;
+ const out = [];
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols && r * cols + c < bays; c++) out.push(driveBay(60 + c * (bw + 8), 15 + r * (bh + 8), bw, bh, false));
+ }
+ return ` ${ears(1100, H, 'rf-ear-dark')}
+ ${out.join('')}
+ ${vents(810, 1040, 18, 7, 6, 36)}${units > 1 ? vents(810, 1040, H - 26, 7, 6, 36) : ''}
+ ${powerButton(1020, H / 2, Math.min(18, H / 5), status)}`;
+ },
+ };
+}
+
+// Front drawings that are full faceplates in faceplates.js (same parts as the UniFi gear)
+const CHASSIS_FACEPLATES = { 'unvr-pro': 'UNVRPRO' };
+
+// The markup for one item's drawn front, or '' when the key is unknown
+function rackFrontSvg(key, ctx) {
+ const spec = RACK_FRONTS[key];
+ if (!spec) return '';
+ return `
+ ${lt.escHtml(`${ctx.name} · ${spec.label}${ctx.status ? ` · ${ctx.status}` : ''}`)}
+ ${spec.draw(ctx)}
+ `;
+}
diff --git a/static/port-inspector.js b/static/port-inspector.js
index 1af6443..f55dacc 100644
--- a/static/port-inspector.js
+++ b/static/port-inspector.js
@@ -9,13 +9,13 @@ const escHtml = s => lt.escHtml(s);
// ── Formatting helpers ───────────────────────────────────────────────────
function fmtShortSpeed(mbps) {
- if (!mbps) return '–';
+ if (!mbps) return '-';
if (mbps >= 1000) return (mbps / 1000).toFixed(0) + 'G';
return mbps + 'M';
}
function fmtRate(bytesPerSec) {
- if (bytesPerSec === null || bytesPerSec === undefined) return '–';
+ 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';
@@ -24,7 +24,7 @@ function fmtRate(bytesPerSec) {
}
function fmtErrors(rate) {
- if (rate === null || rate === undefined) return '–';
+ if (rate === null || rate === undefined) return '-';
if (rate < 0.001) return '0 /s ';
return `${rate.toFixed(3)} /s `;
}
@@ -298,11 +298,11 @@ function renderPanel(swName, idx) {
if (!d) return;
const upStr = d.up ? 'UP ' : 'DOWN ';
- const speedStr = d.speed_mbps ? `${fmtShortSpeed(d.speed_mbps)}bps ` : '–';
+ const speedStr = d.speed_mbps ? `${fmtShortSpeed(d.speed_mbps)}bps ` : '-';
const duplexCls = d.full_duplex ? 'val-good' : (d.up ? 'val-warn' : 'val-neutral');
- const duplexStr = d.up ? `${d.full_duplex ? 'Full' : 'Half'} ` : '–';
+ const duplexStr = d.up ? `${d.full_duplex ? 'Full' : 'Half'} ` : '-';
const autoneg = d.autoneg ? 'On' : 'Off';
- const mediaStr = d.media || '–';
+ const mediaStr = d.media || '-';
const isUplinkBadge = d.is_uplink ? ' UPLINK ' : '';
// PoE section
@@ -321,8 +321,8 @@ function renderPanel(swName, idx) {
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
trafficHtml = `
Traffic
- TX ${fmtRate(d.tx_bytes_rate)}
- RX ${fmtRate(d.rx_bytes_rate)}
`;
+ TX ${fmtRate(d.tx_bytes_rate)}
+ RX ${fmtRate(d.rx_bytes_rate)}
`;
}
// Errors / drops section
@@ -447,14 +447,14 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
const duplexMismatch = swPort.up && svrData.duplex &&
((swFull && !svrFull) || (!swFull && svrFull));
const duplexWarnHtml = duplexMismatch
- ? `⚠ DUPLEX MISMATCH — Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}
`
+ ? `⚠ DUPLEX MISMATCH. Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}
`
: '';
// Detect speed mismatch
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
const speedWarnHtml = speedMismatch
- ? `⚠ SPEED MISMATCH — Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}
`
+ ? `⚠ SPEED MISMATCH. Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}
`
: '';
return `
@@ -474,7 +474,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
Iface ${escHtml(ifaceName)}
-
Speed ${svrData.speed_mbps ? fmtShortSpeed(svrData.speed_mbps) + 'bps' : '–'}
+
Speed ${svrData.speed_mbps ? fmtShortSpeed(svrData.speed_mbps) + 'bps' : '-'}
TX ${fmtRate(svrData.tx_bytes_rate)}
RX ${fmtRate(svrData.rx_bytes_rate)}
TX Err ${fmtErrors(svrData.tx_errs_rate)}
@@ -587,7 +587,7 @@ function runDiagnostic(swName, portIdx) {
})
.catch(e => {
const msg = (e && e.status === 429)
- ? 'Rate limit reached — max 5 diagnostics per minute. Please wait.'
+ ? 'Rate limit reached: max 5 diagnostics per minute. Please wait.'
: 'Error: ' + (e && e.message || 'Request failed');
statusEl.textContent = msg;
});
@@ -666,21 +666,21 @@ function renderDiagnosticResults(d, container) {
const issueRows = [...issues, ...warns, ...infoArr].map(item => {
const cls = issues.includes(item) ? 'diag-val-bad' : warns.includes(item) ? 'diag-val-warn' : 'diag-val-good';
const label = issues.includes(item) ? 'CRIT' : warns.includes(item) ? 'WARN' : 'INFO';
- return `
[${label}] ${escHtml(item.code)} — ${escHtml(item.message)}
`;
+ return `
[${label}] ${escHtml(item.code)} : ${escHtml(item.message)}
`;
}).join('');
// ── Physical layer ──
const carrierVal = secs.carrier === '1' ? '
YES ' :
- secs.carrier === '0' ? '
NO ' : '–';
+ secs.carrier === '0' ? '
NO ' : '-';
const operstateVal = (secs.operstate || '?').toUpperCase();
const opstateCls = secs.operstate === 'up' ? 'diag-val-good' : secs.operstate === 'down' ? 'diag-val-bad' : 'diag-val-warn';
- const speedVal = eth.speed_mbps ? `
${fmtShortSpeed(eth.speed_mbps)}bps ` : '
– ';
+ const speedVal = eth.speed_mbps ? `
${fmtShortSpeed(eth.speed_mbps)}bps ` : '
- ';
const duplexVal = eth.duplex === 'full' ? '
Full ' :
- eth.duplex === 'half' ? '
Half ' : '–';
+ eth.duplex === 'half' ? '
Half ' : '-';
const linkDetVal = eth.link_detected === true ? '
Yes ' :
- eth.link_detected === false ? '
No ' : '–';
+ eth.link_detected === false ? '
No ' : '-';
const autonegVal = eth.auto_neg === true ? '
On ' :
- eth.auto_neg === false ? '
Off ' : '–';
+ eth.auto_neg === false ? '
Off ' : '-';
const physHtml = `
@@ -769,8 +769,8 @@ function renderDiagnosticResults(d, container) {
RX Pause ${pause.rx_pause ? 'On ' : 'Off'}
TX Pause ${pause.tx_pause ? 'On ' : 'Off'} ` : ''}
${hasRing ? `
- RX Ring ${ring.rx_current != null ? ring.rx_current : '–'} / ${ring.rx_max != null ? ring.rx_max : '–'} max
- TX Ring ${ring.tx_current != null ? ring.tx_current : '–'} / ${ring.tx_max != null ? ring.tx_max : '–'} max ` : ''}
+ RX Ring ${ring.rx_current != null ? ring.rx_current : '-'} / ${ring.rx_max != null ? ring.rx_max : '-'} max
+ TX Ring ${ring.tx_current != null ? ring.tx_current : '-'} / ${ring.tx_max != null ? ring.tx_max : '-'} max ` : ''}
`;
}
@@ -799,15 +799,15 @@ function renderDiagnosticResults(d, container) {
-
System ${escHtml(swLldp.system_name || '–')}
-
Port ${escHtml(swLldp.port_id || '–')}
-
Chassis ${escHtml(swLldp.chassis_id || '–')}
+
System ${escHtml(swLldp.system_name || '-')}
+
Port ${escHtml(swLldp.port_id || '-')}
+
Chassis ${escHtml(swLldp.chassis_id || '-')}
${lldpctl.available
- ? `
Neighbor ${escHtml(lldpctl.neighbor_system || '–')}
-
Port ${escHtml(lldpctl.neighbor_port || '–')}
`
+ ? `
Neighbor ${escHtml(lldpctl.neighbor_system || '-')}
+
Port ${escHtml(lldpctl.neighbor_port || '-')}
`
: '
lldpd not running
'}
@@ -838,8 +838,8 @@ function renderDiagnosticResults(d, container) {
Status ${swPort.up ? 'UP ' : 'DOWN '}
- Speed ${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '–'}
- Duplex ${swPort.full_duplex ? 'Full' : (swPort.up ? 'Half ' : '–')}
+ Speed ${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '-'}
+ Duplex ${swPort.full_duplex ? 'Full' : (swPort.up ? 'Half ' : '-')}
TX Err ${fmtErrors(swPort.tx_errs_rate)}
RX Err ${fmtErrors(swPort.rx_errs_rate)}
${swPort.poe_power != null ? `PoE ${swPort.poe_power.toFixed(1)}W ` : ''}
diff --git a/static/rack.js b/static/rack.js
index 0a966a2..d42e686 100644
--- a/static/rack.js
+++ b/static/rack.js
@@ -1,5 +1,5 @@
'use strict';
-/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT */
+/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */
// Rack page: draws the rack front from /api/rack. Switches use their real faceplates,
// patch panels a generic panel; hovering a port lights up every port on the same cable run.
@@ -38,9 +38,10 @@ function isRackFaceplate(model) {
}
// ── Item drawings ───────────────────────────────────────────────────────
-function switchHtml(item) {
- const sw = item.switch;
- return renderFaceplate(sw.model, {
+function switchHtml(item, model) {
+ // A live switch, or a faceplate picked by Front drawing (no port data: drawn unlinked)
+ const sw = item.switch || { model, ports: {}, outlets: [], online: item.status === 'up' };
+ return renderFaceplate(model || sw.model, {
swName: item.device, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, rack: true,
titleFor: (port, idx) => portTitle(port, idx),
cutsheetFor: idx => {
@@ -104,6 +105,48 @@ function shelfHtml(item) {
`;
}
+// Ubiquiti UACC-Rack-Panel-Patch-Blank-24, laid out from the CC0 NetBox devicetype-library image
+// (elevation-images/Ubiquiti/ubiquiti-uacc-rack-panel-patch-blank-24.front.png): a grey steel face,
+// darker ears with oval holes, 24 square keystone openings in one row with the cable-support bar
+// behind them, and a narrow cable slot at each end. A port with a documented cable gets a keystone jack.
+function uaccPanelSvg(item) {
+ const W = 1178, H = 108, x0 = 107, pitch = 40.5, ow = 34, oy = 36, oh = 40;
+ const count = Math.min(item.port_count || 24, 24);
+ const ports = [];
+ for (let n = 1; n <= count; n++) {
+ const x = x0 + (n - 1) * pitch;
+ const m = item.ports[String(n)];
+ const label = item.labels[String(n)] || '';
+ const title = [`Port ${n}`, label && `"${label}"`, m ? m.text : 'not on the cutsheet'].filter(Boolean).join(' · ');
+ const attrs = m ? ` data-run="${m.run.join(' ')}" data-cable="${m.cable_id}" tabindex="0" role="link"` : '';
+ const jack = m ? `
+ ` : '';
+ ports.push(`
+ ${esc(title)}
+
+
+ ${jack}
+
+ ${n}
+ ${label ? `${esc(label.length > 7 ? `${label.slice(0, 6)}…` : label)} ` : ''}
+
+ `);
+ }
+ const hole = (x, y) => ` `;
+ return `
+ ${esc(item.name)}
+
+
+ ${hole(8, 12)}${hole(8, 83)}${hole(W - 30, 12)}${hole(W - 30, 83)}
+
+
+
+
+ ${ports.join('')}
+ `;
+}
+
function plateHtml(item) {
const unlinked = item.kind === 'device' && !item.linked;
const sub = [item.device !== item.name ? item.device : '', KIND_TEXT[item.kind] || item.kind, item.location,
@@ -126,6 +169,9 @@ function itemHtml(item) {
let body;
if (item.through) body = backHtml(item);
else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
+ else if (CHASSIS_FACEPLATES[item.chassis]) body = switchHtml(item, CHASSIS_FACEPLATES[item.chassis]);
+ else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
+ else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item);
else if (item.kind === 'patch_panel') body = panelSvg(item);
else if (item.kind === 'shelf') body = shelfHtml(item);
else body = plateHtml(item);
@@ -149,7 +195,7 @@ function gapRow(top, bottom) {
const n = top - bottom + 1;
return `
-
U${bottom}–${top} · ${n}U free
+
U${bottom}-${top} · ${n}U free
`;
}
diff --git a/static/style.css b/static/style.css
index 2d77b6f..d3362af 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1,5 +1,5 @@
/* ══════════════════════════════════════════════════════════════════════
- GANDALF – App-specific styles (extends LotusGuild base.css)
+ GANDALF: App-specific styles (extends LotusGuild base.css)
base.css handles: body, header, nav, tables, buttons, forms, modals,
badges, toasts, layout shell, and the full design token system.
This file adds only gandalf-specific components.
@@ -96,7 +96,7 @@
content: '…';
}
-/* ── Secondary button – dark-mode definition ─────────────────────────
+/* ── Secondary button: dark-mode definition ─────────────────────────
base.css only defines .lt-btn-secondary in its light-theme block,
so dark mode falls back to the default cyan primary appearance.
This restores a visually distinct secondary look in dark mode. */
@@ -317,7 +317,7 @@
overflow: hidden;
transition: border-color .2s, box-shadow .2s;
}
-/* Corner accent triangle — mirrors test code's status-tinted corner */
+/* Corner accent triangle: mirrors test code's status-tinted corner */
.host-card::after {
content: '';
position: absolute;
@@ -485,7 +485,7 @@
transition: border-color .2s, box-shadow .2s;
overflow: hidden;
}
-/* Top highlight strip — color matches node type / status */
+/* Top highlight strip: color matches node type / status */
.topo-v2-node::before {
content: '';
position: absolute;
@@ -712,7 +712,7 @@
.counter-zero { color: var(--green); }
.counter-nonzero { color: var(--red); text-shadow: var(--glow-red); }
-/* Traffic bars — use lt-progress from base.css */
+/* Traffic bars: use lt-progress from base.css */
.traffic-section { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,107,0,.08); }
.traffic-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.traffic-label { font-size: .62em; color: var(--text-muted); width: 20px; text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
@@ -945,7 +945,7 @@
transition: all .15s;
}
.panel-close:hover { color: var(--red); border-color: var(--red); }
-/* Inspector panel uses lt-divider — compact spacing overrides */
+/* Inspector panel uses lt-divider: compact spacing overrides */
.inspector-panel .lt-divider { margin: 8px 0 4px; }
.inspector-panel .lt-divider-label { color: var(--amber); font-size: .6em; }
.panel-row {
@@ -1190,7 +1190,10 @@ tr.device-row.is-selected { background: var(--bg-tertiary); box-shadow: inset 3p
.fp-screen { fill: #0c1020; stroke: #2a2c34; stroke-width: 1; }
.fp-screen-ring{ fill: none; stroke: #3f7bd9; stroke-width: 2.2; }
.fp-screen-dot { fill: #dfe8f8; }
-.fp-brand { fill: #55565c; font: 600 7.5px Arial, Helvetica, sans-serif; }
+.fp-brand { fill: #2e2f33; font: 700 7.5px Arial, Helvetica, sans-serif; }
+.fp-brand-light{ font-weight: 400; fill: #55565c; }
+.fp-brand-bolt-ring { fill: #2e2f33; }
+.fp-brand-bolt { fill: #e4e4e6; }
.fp-bay { fill: url(#fp-bay-face); stroke: #54555a; stroke-width: 1; }
.fp-bay-edge { fill: #bdbdc1; }
.fp-bay-led { fill: #3a3a3e; }
@@ -1524,3 +1527,60 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.rack-face-btn + .rack-face-btn { border-left: 1px solid var(--border-color); }
.rack-face-btn.active { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.rack-back { opacity: .75; background: repeating-linear-gradient(135deg, var(--rk-plate-b) 0 8px, var(--rk-frame) 8px 16px); }
+
+/* ── Drawn rack fronts (static/fronts.js) ─────────────────────────── */
+.rf-ear, .rf-ear-dark { fill: #2b3037; }
+.rf-ear { fill: #c9cbcf; }
+.rf-slot { fill: #0d1014; }
+.rf-vent { fill: #14181d; }
+.rf-body-dark { fill: #1e2329; stroke: #3a424c; }
+.rf-blank { fill: #262c33; stroke: #3d4651; }
+.rf-bay { fill: #2c333b; stroke: #4c5662; stroke-width: 1.5; }
+.rf-latch { fill: #8a96a3; }
+.rf-bay-led { fill: #3b8bff; opacity: .8; }
+.rf-io { fill: #171b20; stroke: #333b45; }
+.rf-power-ring { fill: none; stroke-width: 3; }
+.rf-power-icon { fill: none; stroke: #c7ced6; stroke-width: 2.4; stroke-linecap: round; }
+.rf-port { fill: #0b0d10; stroke: #6b737d; }
+.rf-port-tongue { fill: #2f6fd8; }
+.rf-port-led { fill: #2fd36b; }
+.rf-lock { fill: #a7b0ba; }
+.rf-lock-slot { fill: #2b3037; }
+.rf-led { fill: #6b737d; }
+.rf-reset { fill: #0b0d10; stroke: #6b737d; }
+.rf-brand { fill: #c3ccd5; font-size: 12px; font-weight: 700; letter-spacing: .14em; font-family: Helvetica, Arial, sans-serif; }
+.rf-small { fill: #4a4f57; font-size: 10px; font-family: Helvetica, Arial, sans-serif; }
+.rf-dark { fill: #1b1e22; font-weight: 700; }
+.rf-door { fill: #121519; stroke: #3a424c; stroke-width: 2; }
+.rf-mesh { stroke: #262c33; stroke-width: 2; opacity: .55; }
+.rf-perf-hole { fill: #000; }
+.rf-door-latch { fill: #3a424c; stroke: #59636f; }
+.rf-bay-num { fill: #8a96a3; font-size: 11px; font-family: Helvetica, Arial, sans-serif; }
+.rf-fan { fill: #15191e; stroke: #2e353e; stroke-width: 6; }
+.rf-fan-hub { fill: #262c33; }
+.rf-fan-blade { fill: #1f252c; stroke: #2e353e; }
+.rf-handle { fill: none; stroke: #8a96a3; stroke-width: 5; }
+.rf-unvr-body { fill: #d9dadc; stroke: #a9abaf; }
+.rf-unvr-strip { fill: #26282c; }
+.rf-unvr-bay { fill: #e9eaec; stroke: #9fa2a7; stroke-width: 1.5; }
+.rf-unvr-rail { fill: #55595f; }
+.rf-unvr-dot { fill: #ffffff; stroke: #c4c6ca; }
+.rf-screen { fill: #0f1726; }
+.rf-screen-ring { fill: none; stroke-width: 3; }
+
+/* Ubiquiti UACC-Rack-Panel-Patch-Blank-24 (static/rack.js uaccPanelSvg) */
+.ua-ear { fill: #6f7277; }
+.ua-hole { fill: #15181c; }
+.ua-face { fill: #8e9095; stroke: #6c6f74; }
+.ua-lip { fill: #a6a8ad; }
+.ua-open { fill: #17191d; }
+.ua-bar { fill: #9a9ca1; }
+.ua-keystone { fill: #101113; stroke: #2b2d31; }
+.ua-keystone-hole { fill: #2f3236; }
+.ua-num { fill: #2b2d31; font-size: 9px; font-family: Arial, Helvetica, sans-serif; }
+.ua-label { fill: #1f2124; font-size: 8.5px; font-weight: 700; font-family: Arial, Helvetica, sans-serif; }
+.ua-mark { fill: none; stroke: transparent; }
+.rack-pp-ok .ua-mark { stroke: #00b860; stroke-width: 2.5; }
+.rack-pp-bad .ua-mark { stroke: #e0312b; stroke-width: 3; }
+.rack-pp-doc .ua-mark { stroke: #4c5662; stroke-width: 2; }
+.rack-pp-port.rack-hl .ua-mark { fill: rgba(0,212,255,.25); stroke: #00b8e6; stroke-width: 3.5; }
diff --git a/templates/base.html b/templates/base.html
index 9d66193..2d9f9d4 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -6,7 +6,7 @@
- {% block title %}Network Monitor{% endblock %} — GANDALF
+ {% block title %}Network Monitor{% endblock %} | GANDALF
@@ -127,7 +127,7 @@
ADMIN
{% endif %}
-
+