Merge pull request 'Rack: drawn fronts (NVR Pro, UACC panel, server cases), faceplate labels, no em/en dashes' (#27) from rack-drawn-fronts into main
Lint / Python (flake8) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 10s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 2m0s
Test / Python Tests (pytest) (push) Successful in 2m4s
Lint / Deploy (push) Successful in 8s

This commit was merged in pull request #27.
This commit is contained in:
2026-11-10 23:45:36 -05:00
27 changed files with 519 additions and 174 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+25 -18
View File
@@ -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
![GANDALF walkthrough: dashboard, link debug with optical readings, switch inspector, and a one-click link diagnostic](docs/img/demo.gif)
@@ -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`,
+11 -9
View File
@@ -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 (
'<h1>401 – Not authenticated</h1>'
'<h1>401: Not authenticated</h1>'
'<p>Please access Gandalf through '
'<a href="https://auth.lotusguild.org">auth.lotusguild.org</a>.</p>',
401,
@@ -211,7 +211,7 @@ def require_auth(f):
safe_user = html.escape(user['username'])
safe_groups = html.escape(', '.join(allowed))
return (
f'<h1>403 – Access denied</h1>'
f'<h1>403: Access denied</h1>'
f'<p>Your account ({safe_user}) is not in an allowed group '
f'({safe_groups}).</p>',
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
+14 -1
View File
@@ -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
+3 -1
View File
@@ -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')
+12 -12
View File
@@ -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}
+10 -10
View File
@@ -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', [])
+1 -1
View File
@@ -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
+13 -13
View File
@@ -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 = `
<div class="modem-error">Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
<span class="modem-stat-note">Last attempt ${lt.escHtml(data.updated || '–')}</span></div>
<span class="modem-stat-note">Last attempt ${lt.escHtml(data.updated || '-')}</span></div>
${_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) {
<div class="modem-stats">
${_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) {
</div>
${_modemChart(data.history)}
${_modemOutages(data.wan_history)}
<div class="modem-stat-note modem-updated">Updated ${lt.escHtml(data.updated || '–')}</div>`;
<div class="modem-stat-note modem-updated">Updated ${lt.escHtml(data.updated || '-')}</div>`;
}
function updateUnifiTable(devices) {
@@ -481,7 +481,7 @@ function updateEventsTable(events, totalActive) {
const ticket = e.ticket_id
? `<a href="${lt.escHtml(ticketBase)}${lt.escHtml(String(e.ticket_id))}" target="_blank"
class="ticket-link">#${lt.escHtml(String(e.ticket_id))}</a>`
: '–';
: '-';
const supBadge = e.is_suppressed
? `<span class="lt-badge badge-suppressed" title="Alert suppressed">🔕 sup</span>`
: '';
@@ -490,7 +490,7 @@ function updateEventsTable(events, totalActive) {
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span>${supBadge}</td>
<td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${lt.escHtml(e.target_name)}</strong></td>
<td>${lt.escHtml(e.target_detail || '–')}</td>
<td>${lt.escHtml(e.target_detail || '-')}</td>
<td class="desc-cell" title="${lt.escHtml(e.description || '')}">${lt.escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
<td class="ts-cell" title="${lt.escHtml(e.first_seen||'')}">${lt.time.ago(_toIso(e.first_seen))}</td>
<td class="ts-cell" title="${lt.escHtml(e.last_seen||'')}">${lt.time.ago(_toIso(e.last_seen))}</td>
@@ -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);
+8 -8
View File
@@ -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 = '<span class="lt-dot lt-dot--red"></span> NO NETWORK CONNECTION — RECONNECTING...';
_offlineBanner.innerHTML = '<span class="lt-dot lt-dot--red"></span> 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', () => {
+13 -11
View File
@@ -112,10 +112,10 @@ function renderCables() {
<thead><tr><th>ID</th><th>From</th><th>To</th><th>Type</th><th>Status</th><th>Check</th>${CS_CAN_EDIT ? '<th class="no-print"></th>' : ''}</tr></thead>
<tbody>${rows.map(c => `
<tr id="cable-${esc(c.id)}" class="${c.problem ? 'cs-row-problem' : ''}${c.status === 'retired' ? ' row-resolved' : ''}">
<td class="cs-id">${c.label ? esc(c.label) : '<span class="val-neutral">–</span>'}${c.label && !c.label_applied && c.status !== 'retired' ? ' <span class="cs-tag" title="The label isn\'t on the cable yet">not stuck on</span>' : ''}</td>
<td class="cs-id">${c.label ? esc(c.label) : '<span class="val-neutral">-</span>'}${c.label && !c.label_applied && c.status !== 'retired' ? ' <span class="cs-tag" title="The label isn\'t on the cable yet">not stuck on</span>' : ''}</td>
<td data-label="From">${endText(c, 'a')}</td>
<td data-label="To">${endText(c, 'b')}${pathText(c) ? `<div class="cs-path">${pathText(c)}</div>` : ''}</td>
<td data-label="Type">${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '–'}</td>
<td data-label="Type">${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '-'}</td>
<td data-label="Status">${esc(c.status)}</td>
<td data-label="Check">${verdictBadge(c)}<div class="cs-detail">${esc(c.detail)}</div></td>
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-cable" data-id="${esc(c.id)}" aria-label="Edit cable ${esc(c.label || c.id)}">Edit</button></td>` : ''}
@@ -180,11 +180,11 @@ function renderPanels() {
const lab = labels[`${p.id}|${n}`];
const cell = (c, side) => c
? `<a class="cs-id" href="#cable-${esc(c.id)}">${esc(c.label || 'unlabeled')}</a> → ${farEnd(c, p.name, n, side)}`
: '<span class="val-neutral">–</span>';
: '<span class="val-neutral">-</span>';
rows.push(`
<tr>
<td class="cs-id">${n}</td>
<td>${lab ? esc(lab.label) + (lab.applied ? '' : ' <span class="cs-tag">not stuck on</span>') : '<span class="val-neutral">–</span>'}
<td>${lab ? esc(lab.label) + (lab.applied ? '' : ' <span class="cs-tag">not stuck on</span>') : '<span class="val-neutral">-</span>'}
${CS_CAN_EDIT ? `<button type="button" class="cs-link-btn no-print" data-action="edit-port-label" data-item="${esc(p.id)}" data-port="${n}" aria-label="Edit label for ${esc(p.name)} port ${n}">edit</button>` : ''}</td>
<td>${cell(front, 'front')}</td>
<td>${cell(rear, 'rear')}</td>
@@ -249,7 +249,7 @@ function renderRack() {
const top = i.rack_u + (i.rack_height || 1) - 1;
return `
<div class="cs-rack-unit" style="--u:${Math.min(i.rack_height || 1, 8)}">
<span class="cs-rack-u">${i.rack_height > 1 ? `U${esc(top)}–${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}</span>
<span class="cs-rack-u">${i.rack_height > 1 ? `U${esc(top)}-${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}</span>
<span class="cs-rack-name">${deviceLink(i.name)}</span>
<span class="cs-rack-kind">${esc(KIND_TEXT[i.kind] || i.kind)}</span>
</div>`;
@@ -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() {
<tr id="${i.kind === 'patch_panel' ? '' : `item-${esc(i.id)}`}">
<td>${deviceLink(i.name)}</td>
<td>${esc(KIND_TEXT[i.kind] || i.kind)}</td>
<td>${esc(i.port_count || '–')}</td>
<td>${esc(i.port_count || '-')}</td>
<td>${rackText(i)}</td>
<td>${esc(i.location || '–')}</td>
<td>${esc(i.location || '-')}</td>
<td>${esc(i.notes || '')}</td>
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-item" data-id="${esc(i.id)}" aria-label="Edit ${esc(i.name)}">Edit</button></td>` : ''}
</tr>`).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 => `<option value="${esc(t)}">${esc(t || '–')}</option>`).join('');
form.cable_type.innerHTML = types.map(t => `<option value="${esc(t)}">${esc(t || '-')}</option>`).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 => `<option value="${esc(r)}">`).join('');
document.getElementById('cs-known-devices').innerHTML = (_cs.devices || []).map(d => `<option value="${esc(d)}">`).join('');
form.chassis.innerHTML = '<option value="">Automatic (faceplate or plain plate)</option>' +
Object.entries(_cs.chassis || {}).map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`).join('');
const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id));
form.shelf.innerHTML = '<option value="">Not on a shelf</option>' +
shelves.map(i => `<option value="${esc(i.name)}">${esc(i.name)}</option>`).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');
+15 -15
View File
@@ -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) {
<div class="sfp-vendor-row">${vendor}</div>
<div class="sfp-grid">
${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: `
<div class="link-stats-grid">
${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 ? `<a class="device-link" href="${esc(deviceUrl(r.neighbor.name))}">${esc(r.neighbor.name)}</a>` : esc(r.neighbor.chassis || r.neighbor.port || ''))
: '–';
: '-';
return `
<tr class="device-row${r.up ? '' : ' device-row--down'}"${isPorts ? ` data-port-idx="${esc(r.num)}"` : ''}>
<td><button type="button" class="device-row-toggle" data-row="${esc(r.id)}"${isPorts ? '' : ` aria-expanded="${open}"`} aria-label="Details for ${esc(r.label)}">${open ? '▾' : '▸'}</button></td>
<td><span class="dot-${r.up ? 'up' : 'down'}"></span></td>
<td class="device-row-name">${r.num !== undefined ? `<span class="port-badge port-badge-num">#${esc(r.num)}</span> ` : ''}${esc(r.label)}<div class="device-row-sub">${esc(r.sub)}</div></td>
<td class="device-num">${r.up ? fmtSpeed(d.speed_mbps) : '–'}</td>
<td class="device-num">${r.up ? fmtSpeed(d.speed_mbps) : '-'}</td>
<td class="device-num device-col-wide">${fmtRate(d.rx_bytes_rate)}</td>
<td class="device-num device-col-wide">${fmtRate(d.tx_bytes_rate)}</td>
${isPorts ? `<td class="device-col-wide">${neighbor}</td>` : ''}
@@ -241,7 +241,7 @@ function renderUplinks(d) {
const rows = d.uplinks.map(u => `
<tr>
<td><span class="dot-${u.up ? 'up' : 'down'}"></span></td>
<td>${esc(u.host_interface || '–')}</td>
<td>${esc(u.host_interface || '-')}</td>
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}">${esc(u.switch)}</a></td>
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}#port-${esc(u.port_idx)}">#${esc(u.port_idx)} ${esc(u.port)}</a></td>
<td>${fmtSpeed(u.speed_mbps)}</td>
@@ -259,8 +259,8 @@ function renderWan(d) {
const rows = names.map(n => {
const w = wan[n];
return `<tr><td><span class="dot-${w.up ? 'up' : 'down'}"></span></td><td>${esc(n.toUpperCase())}</td>
<td>${fmtSpeed(w.speed_mbps)}</td><td>${w.latency_ms === null || w.latency_ms === undefined ? '–' : `${esc(w.latency_ms)} ms`}</td>
<td>${w.availability === null || w.availability === undefined ? '–' : `${esc(w.availability)}%`}</td></tr>`;
<td>${fmtSpeed(w.speed_mbps)}</td><td>${w.latency_ms === null || w.latency_ms === undefined ? '-' : `${esc(w.latency_ms)} ms`}</td>
<td>${w.availability === null || w.availability === undefined ? '-' : `${esc(w.availability)}%`}</td></tr>`;
}).join('');
return section('WAN', `<div class="lt-table-wrap"><table class="lt-table">
<caption class="lt-sr-only">WAN connections</caption>
@@ -275,8 +275,8 @@ function renderOutlets(d) {
<tr>
<td><span class="dot-${o.on ? 'up' : 'down'}"></span></td>
<td>#${esc(o.index)}</td>
<td>${esc(o.name || '–')}</td>
<td class="device-num">${o.power_w === null || o.power_w === undefined ? '–' : `${o.power_w.toFixed(1)} W`}</td>
<td>${esc(o.name || '-')}</td>
<td class="device-num">${o.power_w === null || o.power_w === undefined ? '-' : `${o.power_w.toFixed(1)} W`}</td>
<td>${o.auto_power_cycle ? '<span class="lt-badge">auto power-cycle</span>' : ''}</td>
</tr>`).join('');
return section('Outlets', `<div class="lt-table-wrap"><table class="lt-table">
@@ -295,7 +295,7 @@ function renderEvents(events) {
<tr class="${e.resolved_at ? '' : `row-${esc(e.severity)}`}">
<td><span class="lt-badge badge-${e.resolved_at ? 'resolved' : esc(e.severity)}">${esc(e.severity)}</span>${e.is_suppressed && !e.resolved_at ? '<span class="lt-badge badge-suppressed">🔕 sup</span>' : ''}</td>
<td>${esc(e.event_type.replace(/_/g, ' '))}</td>
<td>${esc(e.target_detail || '–')}</td>
<td>${esc(e.target_detail || '-')}</td>
<td class="desc-cell">${esc(e.description || '')}</td>
<td class="ts-cell" title="${esc(e.first_seen || '')}">${esc(lt.time.ago(_toIso(e.first_seen)))}</td>
<td>${e.resolved_at ? `resolved ${esc(lt.time.ago(_toIso(e.resolved_at)))}` : '<strong>active</strong>'}</td>
+3 -3
View File
@@ -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 '<span class="val-neutral">–</span>';
if (!id) return '<span class="val-neutral">-</span>';
const base = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url) || '';
return base ? `<a href="${esc(base + id)}" target="_blank" rel="noopener">#${esc(id)}</a>` : `#${esc(id)}`;
}
@@ -157,7 +157,7 @@ function renderTable(data) {
const next = end < data.total;
document.getElementById('ev-pager').innerHTML = (prev || next) ? `
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-page="${data.offset - data.page_size}" ${prev ? '' : 'disabled'}>← Newer</button>
<span>${data.offset + 1}–${end} of ${data.total}</span>
<span>${data.offset + 1}-${end} of ${data.total}</span>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-page="${end}" ${next ? '' : 'disabled'}>Older →</button>` : '';
}
+48 -5
View File
@@ -225,7 +225,21 @@ function fpQuadScreen(x, y, s) {
</g>`;
}
const fpBrand = (x, y, text) => `<text class="fp-brand" x="${x}" y="${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
? `<g transform="translate(${x + textW / 2 + size * 0.75} ${y - size * 0.4})">
<circle class="fp-brand-bolt-ring" r="${size * 0.62}"/>
<path class="fp-brand-bolt" transform="scale(${size / 11})" d="M1 -5 L-3 1 H0 L-1 5 L3 -1 H0 Z"/></g>`
: '';
return `<text class="fp-brand" x="${x}" y="${y}" text-anchor="middle" style="font-size:${size}px">${bold}${light
? `<tspan class="fp-brand-light" dx="${size * 0.25}">${light}</tspan>` : ''}</text>${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 = {
<rect class="fp-screen" x="15" y="25" width="56" height="56" rx="3"/>
<circle class="fp-screen-ring" cx="43" cy="51" r="13"/>
<circle class="fp-screen-dot" cx="43" cy="51" r="4.5"/>
${fpBrand(16, 94, 'Dream Machine Pro')}
${fpModel(43, 94, 'UDM', 'Pro')}
<rect class="fp-bay" x="447" y="19" width="259" height="66" rx="1.5"/>
<rect class="fp-bay-edge" x="447.8" y="19.8" width="3.5" height="64.4"/>
<circle class="fp-bay-led" cx="681" cy="52" r="1.6"/>
@@ -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) => `<rect class="fp-bay" x="${x}" y="${y}" width="283" height="76" rx="1.5"/>
<rect class="fp-bay-edge" x="${x + 1}" y="${y + 1}" width="4.5" height="74"/>
<circle class="fp-bay-led" cx="${x + 255}" cy="${y + 38}" r="2.2"/>
<text class="fp-label" x="${x + 141}" y="${y + 89}" text-anchor="middle">${n}</text>`;
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('')}
<rect class="fp-block" x="1014" y="52" width="104" height="44" rx="1"/>
${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 })}
<rect class="fp-block" x="563" y="19" width="234" height="69" rx="1"/>
<rect class="fp-block" x="797.5" y="19" width="233" height="69" rx="1"/>
<rect class="fp-divider" x="1083" y="45" width="39" height="16"/>
@@ -321,7 +364,7 @@ const FACEPLATES = {
const dividers = [876.4, 925.6, 975.6, 1025]
.map(x => `<rect class="fp-divider" x="${x}" y="46.6" width="36" height="15"/>`).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}
<rect class="fp-speed-bar" x="874" y="99" width="188" height="4" rx="1"/>
${fpReset(1078.6, 95)}`;
@@ -396,7 +439,7 @@ const FACEPLATES = {
<rect class="fp-ear-hole" x="${x + 13}" y="189" width="27" height="17" rx="8.5"/>`;
return `${ear(0)}${ear(1126)}
<rect class="fp-body-metal" x="48" y="1" width="1078" height="213" rx="1"/>
${fpQuadScreen(61, 132, 56)}${fpBrand(68, 199, 'USP PDU Pro')}
${fpQuadScreen(61, 132, 56)}${fpModel(89, 199, 'USP', 'PDU Pro')}
<circle class="fp-button" cx="1053" cy="53" r="4.5"/>
<rect class="fp-block" x="977" y="160" width="39" height="32.5" rx="1"/>
<rect class="fp-block" x="1032" y="130" width="41" height="62.5" rx="1"/>
+148
View File
@@ -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 => `<rect class="rf-slot" x="${x}" y="${y - 9}" width="9" height="18" rx="4"/>`).join('');
return `<rect class="${cls}" x="0" y="0" width="34" height="${h}" rx="3"/>${holes(12)}
<rect class="${cls}" x="${w - 34}" y="0" width="34" height="${h}" rx="3"/>${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(`<rect class="rf-vent" x="${x}" y="${y}" width="${seg}" height="${h}" rx="${h / 2}"/>`);
return out.join('');
}
function powerButton(cx, cy, r, status) {
return `<circle class="rf-power-ring" cx="${cx}" cy="${cy}" r="${r}" style="stroke:${frontLed(status)}"/>
<path class="rf-power-icon" d="M${cx - r * 0.35} ${cy - r * 0.3} a${r * 0.48} ${r * 0.48} 0 1 0 ${r * 0.7} 0 M${cx} ${cy - r * 0.55} v${r * 0.5}"/>`;
}
function usbA(x, y) {
return `<rect class="rf-port" x="${x}" y="${y}" width="22" height="9" rx="1"/><rect class="rf-port-tongue" x="${x + 3}" y="${y + 2}" width="16" height="3"/>`;
}
function usbC(x, y) {
return `<rect class="rf-port" x="${x}" y="${y}" width="16" height="6" rx="3"/>`;
}
function keyLock(cx, cy) {
return `<circle class="rf-lock" cx="${cx}" cy="${cy}" r="9"/><rect class="rf-lock-slot" x="${cx - 1.5}" y="${cy - 6}" width="3" height="12" rx="1"/>`;
}
function brand(x, y, text, anchor = 'start') {
return `<text class="rf-brand" x="${x}" y="${y}" text-anchor="${anchor}">${text}</text>`;
}
function driveBay(x, y, w, h, vertical) {
// A hot-swap bay: tray face, latch and activity light
const latch = vertical
? `<rect class="rf-latch" x="${x + w / 2 - 6}" y="${y + h - 26}" width="12" height="18" rx="2"/>`
: `<rect class="rf-latch" x="${x + w - 26}" y="${y + h / 2 - 6}" width="18" height="12" rx="2"/>`;
const led = vertical ? `<circle class="rf-bay-led" cx="${x + w / 2}" cy="${y + 10}" r="2.5"/>`
: `<circle class="rf-bay-led" cx="${x + 10}" cy="${y + h / 2}" r="2.5"/>`;
return `<rect class="rf-bay" x="${x}" y="${y}" width="${w}" height="${h}" rx="3"/>${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)}
<text class="rf-bay-num" x="${117 + i * 86}" y="396" text-anchor="middle">${i + 1}</text>`).join('');
return `<rect class="rf-body-dark" width="1100" height="420" rx="3"/>${ears(1100, 420, 'rf-ear-dark')}
<rect class="rf-blank" x="78" y="30" width="418" height="98" rx="3"/>
<rect class="rf-blank" x="510" y="30" width="418" height="98" rx="3"/>
${vents(98, 480, 74, 7, 8, 40)}${vents(530, 912, 74, 7, 8, 40)}
${bays}
<rect class="rf-io" x="950" y="30" width="104" height="360" rx="4"/>
${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 => `<circle class="rf-fan" cx="${cx}" cy="210" r="112"/>
<circle class="rf-fan-hub" cx="${cx}" cy="210" r="30"/>
${[0, 72, 144, 216, 288].map(a => `<path class="rf-fan-blade" transform="rotate(${a} ${cx} 210)" d="M${cx} 180 q40 -40 70 -10 q-30 10 -70 40 z"/>`).join('')}`;
const handle = x => `<rect class="rf-handle" x="${x}" y="60" width="16" height="300" rx="8"/>`;
return `<rect class="rf-body-dark" width="1100" height="420" rx="3"/>${ears(1100, 420, 'rf-ear-dark')}
${handle(36)}${handle(1048)}
<defs><pattern id="rf-perf" width="9" height="9" patternUnits="userSpaceOnUse">
<circle class="rf-perf-hole" cx="4.5" cy="4.5" r="2.4"/></pattern></defs>
<rect class="rf-door" x="64" y="16" width="880" height="388" rx="6"/>
${fan(225)}${fan(504)}${fan(783)}
<rect class="rf-mesh" x="80" y="32" width="848" height="356" rx="4" fill="url(#rf-perf)"/>
<rect class="rf-door-latch" x="900" y="180" width="26" height="60" rx="5"/>
<rect class="rf-io" x="954" y="16" width="86" height="388" rx="4"/>
${powerButton(997, 62, 18, status)}
<circle class="rf-reset" cx="997" cy="104" r="5"/>
<circle class="rf-led" cx="985" cy="130" r="3.5" style="fill:${frontLed(status)}"/><circle class="rf-led" cx="1009" cy="130" r="3.5"/>
${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 `<rect class="rf-body-dark" width="1100" height="${H}" rx="3"/>${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 `<svg class="rack-svg rack-front" viewBox="0 0 ${spec.width} ${spec.height}" preserveAspectRatio="none"
role="img" aria-label="${lt.escHtml(`${ctx.name}: ${spec.label}${ctx.status ? `, ${ctx.status}` : ''}`)}">
<title>${lt.escHtml(`${ctx.name} · ${spec.label}${ctx.status ? ` · ${ctx.status}` : ''}`)}</title>
${spec.draw(ctx)}
</svg>`;
}
+27 -27
View File
@@ -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 '<span class="val-good">0 /s</span>';
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
}
@@ -298,11 +298,11 @@ function renderPanel(swName, 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">${fmtShortSpeed(d.speed_mbps)}bps</span>` : '–';
const speedStr = d.speed_mbps ? `<span class="val-cyan">${fmtShortSpeed(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 duplexStr = d.up ? `<span class="${duplexCls}">${d.full_duplex ? 'Full' : 'Half'}</span>` : '-';
const autoneg = d.autoneg ? 'On' : 'Off';
const mediaStr = d.media || '–';
const mediaStr = d.media || '-';
const isUplinkBadge = d.is_uplink ? ' <span class="port-badge port-badge-uplink">UPLINK</span>' : '';
// PoE section
@@ -321,8 +321,8 @@ function renderPanel(swName, idx) {
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
trafficHtml = `
<div class="lt-divider"><span class="lt-divider-label">Traffic</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit — outgoing from this port">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Receive — incoming to this port">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit: outgoing from this port">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Receive: incoming to this port">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
}
// 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
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH — Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH. Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
: '';
// Detect speed mismatch
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
const speedWarnHtml = speedMismatch
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}</div>`
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH. Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}</div>`
: '';
return `
@@ -474,7 +474,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
<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 ? fmtShortSpeed(svrData.speed_mbps) + 'bps' : '–'}</span></div>
<div class="path-row"><span>Speed</span><span>${svrData.speed_mbps ? fmtShortSpeed(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>
@@ -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 `<div class="diag-issue-row"><span class="${cls}">[${label}]</span> <span class="diag-code">${escHtml(item.code)}</span> — ${escHtml(item.message)}</div>`;
return `<div class="diag-issue-row"><span class="${cls}">[${label}]</span> <span class="diag-code">${escHtml(item.code)}</span>: ${escHtml(item.message)}</div>`;
}).join('');
// ── Physical layer ──
const carrierVal = secs.carrier === '1' ? '<span class="diag-val-good">YES</span>' :
secs.carrier === '0' ? '<span class="diag-val-bad">NO</span>' : '–';
secs.carrier === '0' ? '<span class="diag-val-bad">NO</span>' : '-';
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 ? `<span class="diag-val-good">${fmtShortSpeed(eth.speed_mbps)}bps</span>` : '<span class="diag-val-warn">–</span>';
const speedVal = eth.speed_mbps ? `<span class="diag-val-good">${fmtShortSpeed(eth.speed_mbps)}bps</span>` : '<span class="diag-val-warn">-</span>';
const duplexVal = eth.duplex === 'full' ? '<span class="diag-val-good">Full</span>' :
eth.duplex === 'half' ? '<span class="diag-val-bad">Half</span>' : '–';
eth.duplex === 'half' ? '<span class="diag-val-bad">Half</span>' : '-';
const linkDetVal = eth.link_detected === true ? '<span class="diag-val-good">Yes</span>' :
eth.link_detected === false ? '<span class="diag-val-bad">No</span>' : '–';
eth.link_detected === false ? '<span class="diag-val-bad">No</span>' : '-';
const autonegVal = eth.auto_neg === true ? '<span class="diag-val-good">On</span>' :
eth.auto_neg === false ? '<span class="diag-val-warn">Off</span>' : '–';
eth.auto_neg === false ? '<span class="diag-val-warn">Off</span>' : '-';
const physHtml = `
<div class="diag-section">
@@ -769,8 +769,8 @@ function renderDiagnosticResults(d, container) {
<tr><td>RX Pause</td><td>${pause.rx_pause ? '<span class="diag-val-good">On</span>' : 'Off'}</td></tr>
<tr><td>TX Pause</td><td>${pause.tx_pause ? '<span class="diag-val-good">On</span>' : 'Off'}</td></tr>` : ''}
${hasRing ? `
<tr><td>RX Ring</td><td>${ring.rx_current != null ? ring.rx_current : '–'} / ${ring.rx_max != null ? ring.rx_max : '–'} max</td></tr>
<tr><td>TX Ring</td><td>${ring.tx_current != null ? ring.tx_current : '–'} / ${ring.tx_max != null ? ring.tx_max : '–'} max</td></tr>` : ''}
<tr><td>RX Ring</td><td>${ring.rx_current != null ? ring.rx_current : '-'} / ${ring.rx_max != null ? ring.rx_max : '-'} max</td></tr>
<tr><td>TX Ring</td><td>${ring.tx_current != null ? ring.tx_current : '-'} / ${ring.tx_max != null ? ring.tx_max : '-'} max</td></tr>` : ''}
</table>
</div>`;
}
@@ -799,15 +799,15 @@ function renderDiagnosticResults(d, container) {
<div class="path-debug-cols">
<div class="path-col">
<div class="path-col-header">Switch sees</div>
<div class="path-row"><span>System</span><span>${escHtml(swLldp.system_name || '–')}</span></div>
<div class="path-row"><span>Port</span><span>${escHtml(swLldp.port_id || '–')}</span></div>
<div class="path-row"><span>Chassis</span><span>${escHtml(swLldp.chassis_id || '–')}</span></div>
<div class="path-row"><span>System</span><span>${escHtml(swLldp.system_name || '-')}</span></div>
<div class="path-row"><span>Port</span><span>${escHtml(swLldp.port_id || '-')}</span></div>
<div class="path-row"><span>Chassis</span><span>${escHtml(swLldp.chassis_id || '-')}</span></div>
</div>
<div class="path-col">
<div class="path-col-header">Server lldpctl</div>
${lldpctl.available
? `<div class="path-row"><span>Neighbor</span><span>${escHtml(lldpctl.neighbor_system || '–')}</span></div>
<div class="path-row"><span>Port</span><span>${escHtml(lldpctl.neighbor_port || '–')}</span></div>`
? `<div class="path-row"><span>Neighbor</span><span>${escHtml(lldpctl.neighbor_system || '-')}</span></div>
<div class="path-row"><span>Port</span><span>${escHtml(lldpctl.neighbor_port || '-')}</span></div>`
: '<div class="path-row"><span class="diag-val-warn">lldpd not running</span></div>'}
</div>
</div>
@@ -838,8 +838,8 @@ function renderDiagnosticResults(d, container) {
<div class="diag-section-header">Switch Port Summary</div>
<table class="diag-table">
<tr><td>Status</td><td>${swPort.up ? '<span class="diag-val-good">UP</span>' : '<span class="diag-val-bad">DOWN</span>'}</td></tr>
<tr><td>Speed</td><td>${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '–'}</td></tr>
<tr><td>Duplex</td><td>${swPort.full_duplex ? 'Full' : (swPort.up ? '<span class="diag-val-bad">Half</span>' : '–')}</td></tr>
<tr><td>Speed</td><td>${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '-'}</td></tr>
<tr><td>Duplex</td><td>${swPort.full_duplex ? 'Full' : (swPort.up ? '<span class="diag-val-bad">Half</span>' : '-')}</td></tr>
<tr><td>TX Err</td><td>${fmtErrors(swPort.tx_errs_rate)}</td></tr>
<tr><td>RX Err</td><td>${fmtErrors(swPort.rx_errs_rate)}</td></tr>
${swPort.poe_power != null ? `<tr><td>PoE</td><td><span class="val-amber">${swPort.poe_power.toFixed(1)}W</span></td></tr>` : ''}
+51 -5
View File
@@ -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) {
</div>`;
}
// 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 ? `<rect class="ua-keystone" x="${x + 3}" y="${oy + 3}" width="${ow - 6}" height="${oh - 6}" rx="2"/>
<path class="ua-keystone-hole" d="M${x + 7} ${oy + 13} h20 v14 h-4 v4 h-12 v-4 h-4 z"/>` : '';
ports.push(`<g class="rack-pp-port ua-port${m ? ` rack-pp-${m.mark}` : ''}"${attrs}>
<title>${esc(title)}</title>
<rect class="ua-open" x="${x}" y="${oy}" width="${ow}" height="${oh}"/>
<rect class="ua-bar" x="${x}" y="${oy + 19}" width="${ow}" height="5"/>
${jack}
<rect class="rack-pp-jack ua-mark" x="${x - 1}" y="${oy - 1}" width="${ow + 2}" height="${oh + 2}" rx="1"/>
<text class="ua-num" x="${x + ow / 2}" y="${oy - 6}" text-anchor="middle">${n}</text>
${label ? `<text class="ua-label" x="${x + ow / 2}" y="${oy + oh + 12}" text-anchor="middle">${esc(label.length > 7 ? `${label.slice(0, 6)}…` : label)}</text>` : ''}
<rect class="rack-pp-hit" x="${x - 3}" y="${oy - 16}" width="${pitch}" height="${oh + 32}"/>
</g>`);
}
const hole = (x, y) => `<rect class="ua-hole" x="${x}" y="${y}" width="22" height="13" rx="6.5"/>`;
return `<svg class="rack-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="group"
aria-label="${esc(item.name)} patch panel">
<title>${esc(item.name)}</title>
<rect class="ua-ear" x="0" y="2" width="44" height="${H - 4}" rx="3"/>
<rect class="ua-ear" x="${W - 44}" y="2" width="44" height="${H - 4}" rx="3"/>
${hole(8, 12)}${hole(8, 83)}${hole(W - 30, 12)}${hole(W - 30, 83)}
<rect class="ua-face" x="36" y="0" width="${W - 72}" height="${H}" rx="2"/>
<rect class="ua-lip" x="36" y="0" width="${W - 72}" height="4"/>
<rect class="ua-open" x="62" y="${oy}" width="28" height="${oh}"/><rect class="ua-bar" x="62" y="${oy + 19}" width="28" height="5"/>
<rect class="ua-open" x="${W - 95}" y="${oy}" width="28" height="${oh}"/><rect class="ua-bar" x="${W - 95}" y="${oy + 19}" width="28" height="5"/>
${ports.join('')}
</svg>`;
}
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 `<div class="rack-row rack-row-gap" data-gap="${n}" style="--n:${n}">
<div class="rack-u" aria-hidden="true"></div>
<div class="rack-gap">U${bottom}–${top} · ${n}U free</div>
<div class="rack-gap">U${bottom}-${top} · ${n}U free</div>
</div>`;
}
+67 -7
View File
@@ -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; }
+5 -5
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#030508">
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%23030508'/%3E%3Cpath d='M8 2l5 3v6l-5 3-5-3V5z' fill='none' stroke='%2300e5ff' stroke-width='1.5'/%3E%3C/svg%3E">
<title>{% block title %}Network Monitor{% endblock %} — GANDALF</title>
<title>{% block title %}Network Monitor{% endblock %} | GANDALF</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -127,7 +127,7 @@
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
{% endif %}
<!-- Notification bell — shows active monitoring alerts -->
<!-- Notification bell: shows active monitoring alerts -->
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
<button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn"
@@ -187,7 +187,7 @@
{% block content %}{% endblock %}
</main>
<!-- FOOTER — context-sensitive per page -->
<!-- FOOTER: context-sensitive per page -->
<footer class="lt-footer" role="contentinfo">
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
{% if request.endpoint == 'index' %}
@@ -206,7 +206,7 @@
<span>GANDALF &mdash; TDS v1.2</span>
</footer>
<!-- QUICK-SUPPRESS MODAL — available on all pages via [S] shortcut -->
<!-- QUICK-SUPPRESS MODAL: available on all pages via [S] shortcut -->
<div id="suppress-modal" class="lt-modal-overlay"
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title" aria-hidden="true">
<div class="lt-modal">
@@ -422,7 +422,7 @@
});
})();
// ── Notification Bell — shows active monitoring alerts ────────────────
// ── Notification Bell: shows active monitoring alerts ────────────────
(function() {
const bell = document.getElementById('lt-notif-bell');
const panel = document.getElementById('lt-notif-panel');
+5
View File
@@ -263,6 +263,11 @@
<input class="lt-input" id="cs-item-h" name="rack_height" type="number" min="1" max="20">
</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-chassis">Front drawing</label>
<select class="lt-select" id="cs-item-chassis" name="chassis"><option value="">Automatic (faceplate or plain plate)</option></select>
<div class="lt-field-hint">For gear Gandalf has no live faceplate for: the NVR, or a server's case.</div>
</div>
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-item-rack">Rack</label>
+13 -13
View File
@@ -103,7 +103,7 @@
<div id="events-table-wrap">
{% if events %}
{% if total_active is defined and total_active > events|length %}
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts — use the search box to filter, or <a href="/api/events?limit=1000" target="_blank" rel="noopener">export all as JSON</a></div>
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts. Use the search box to filter, or <a href="/api/events?limit=1000" target="_blank" rel="noopener">export all as JSON</a></div>
{% endif %}
<div class="lt-table-wrap">
<table class="lt-table" id="events-table">
@@ -132,7 +132,7 @@
</td>
<td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '–' }}</td>
<td>{{ e.target_detail or '-' }}</td>
<td class="desc-cell" title="{{ e.description | e }}">{{ e.description | truncate(60) }}</td>
<td class="ts-cell" title="{{ e.first_seen }}">
<span class="event-age" data-ts="{{ e.first_seen }}">{{ e.first_seen }}</span>
@@ -145,7 +145,7 @@
{% if e.ticket_id %}
<a href="{{ config.ticket_api.web_url }}{{ e.ticket_id }}" target="_blank"
class="ticket-link">#{{ e.ticket_id }}</a>
{% else %}–{% endif %}
{% else %}-{% endif %}
</td>
<td>
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
@@ -335,7 +335,7 @@
<span class="topo-v2-icon">◈</span>
<span class="topo-v2-label">BGW320</span>
<span class="topo-v2-sub">AT&amp;T Gateway</span>
<span class="topo-v2-sub" id="topo-modem-rx">Rx –</span>
<span class="topo-v2-sub" id="topo-modem-rx">Rx -</span>
</div>
</div>
@@ -346,7 +346,7 @@
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 2: Router – UDM-Pro
TIER 2: Router, UDM-Pro
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-router">
@@ -401,7 +401,7 @@
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 4 connecting bus – two rails (10G green + 1G amber dashed)
TIER 4 connecting bus: two rails (10G green + 1G amber dashed)
showing dual-homing for all 6 servers
══════════════════════════════════════════════════════════ -->
<div class="topo-bus-section">
@@ -421,8 +421,8 @@
<!-- ── Host nodes with drop wires ── -->
<div class="topo-v2-hosts">
{%- set all_defs = [
('compute-storage-gpu-01', 'csg-01', 'RU4–12', 'Ceph · VLAN90', False),
('compute-storage-01', 'cs-01', 'RU14–17', 'Ceph · VLAN90', False),
('compute-storage-gpu-01', 'csg-01', 'RU4-12', 'Ceph · VLAN90', False),
('compute-storage-01', 'cs-01', 'RU14-17', 'Ceph · VLAN90', False),
('storage-01', 'sto-01', 'rack', 'Ceph · VLAN90', False),
('monitor-01', 'mon-01', 'ZimaBoard', 'mgmt', False),
('monitor-02', 'mon-02', 'ZimaBoard', 'mgmt', False),
@@ -443,7 +443,7 @@
<span class="topo-v2-label">{{ hlabel }}</span>
<span class="topo-v2-sub">{{ hsub }}</span>
<span class="topo-v2-vlan">{{ hvlan }}</span>
<span class="topo-badge topo-badge-{{ st }}">{{ st if st != 'unknown' else '–' }}</span>
<span class="topo-badge topo-badge-{{ st }}">{{ st if st != 'unknown' else '-' }}</span>
</div>
</div>
{%- endfor -%}
@@ -487,7 +487,7 @@
&nbsp;·&nbsp;
<span class="lt-badge badge-resolved">{{ e.severity }}</span>
&nbsp;·&nbsp; duration
<span class="event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}">–</span>
<span class="event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}">-</span>
</div>
</div>
{% endfor %}
@@ -547,9 +547,9 @@
// ── Event duration (resolved_at - first_seen) ──────────────────
function fmtDuration(firstTs, resolvedTs) {
if (!firstTs || !resolvedTs) return '–';
if (!firstTs || !resolvedTs) return '-';
const secs = Math.floor((new Date(_toIso(resolvedTs)) - new Date(_toIso(firstTs))) / 1000);
if (secs < 0) return '–';
if (secs < 0) return '-';
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs/60)}m`;
if (secs < 86400) return `${Math.floor(secs/3600)}h ${Math.floor((secs%3600)/60)}m`;
@@ -602,7 +602,7 @@
if (node) window.location.href = deviceUrl(node.dataset.host);
});
// Stat card clicks — filter events table by severity
// Stat card clicks: filter events table by severity
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
card.addEventListener('click', () => {
const sev = card.dataset.statFilter;
+1
View File
@@ -84,5 +84,6 @@
{% block scripts %}
<script>const RACK_CAN_EDIT = {{ 'true' if can_edit else 'false' }};</script>
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
<script src="{{ url_for('static', filename='fronts.js') }}"></script>
<script src="{{ url_for('static', filename='rack.js') }}"></script>
{% endblock %}
+3 -3
View File
@@ -104,7 +104,7 @@
{%- set _sup_badge = {'host':'badge-warning','interface':'badge-info','unifi_device':'badge-purple','all':'badge-critical'} -%}
<td><span class="lt-badge {{ _sup_badge.get(s.target_type, 'badge-neutral') }}">{{ s.target_type }}</span></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.suppressed_by }}</td>
<td class="ts-cell">{{ s.created_at }}</td>
@@ -154,7 +154,7 @@
<tr class="{% if not s.active %}row-resolved{% endif %}">
<td>{{ s.target_type }}</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.suppressed_by }}</td>
<td class="ts-cell">{{ s.created_at }}</td>
@@ -243,7 +243,7 @@
<tr id="sup-row-${s.id}">
<td><span class="lt-badge ${SUP_BADGE[s.target_type] || 'badge-neutral'}">${lt.escHtml(s.target_type)}</span></td>
<td>${lt.escHtml(s.target_name || 'all')}</td>
<td>${lt.escHtml(s.target_detail || '–')}</td>
<td>${lt.escHtml(s.target_detail || '-')}</td>
<td>${lt.escHtml(s.reason)}</td>
<td>${lt.escHtml(s.suppressed_by)}</td>
<td class="ts-cell">${lt.escHtml(s.created_at || '')}</td>
+6 -2
View File
@@ -381,8 +381,8 @@ class TestHealth:
class TestPageTitles:
@pytest.mark.parametrize('path,title', [('/', 'Dashboard — GANDALF'), ('/events', 'Alert History — GANDALF'),
('/cutsheet', 'Cutsheet — GANDALF')])
@pytest.mark.parametrize('path,title', [('/', 'Dashboard | GANDALF'), ('/events', 'Alert History | GANDALF'),
('/cutsheet', 'Cutsheet | GANDALF')])
def test_name_appears_once(self, client, path, title):
gandalf._cutsheet_schema_ready = True
html = client.get(path, headers=AUTH).get_data(as_text=True)
@@ -490,6 +490,10 @@ class TestRackRacksShelvesLinks:
item = client.get('/api/rack', headers=AUTH).get_json()['items'][0]
assert not item['linked'] and item['url'] == '/cutsheet#item-1' and 'switch' not in item
def test_drawn_front_is_passed_through(self, client):
self._items({'name': 'UNVR-Pro', 'kind': 'device', 'rack_u': 5, 'rack_height': 2, 'chassis': 'unvr-pro'})
assert client.get('/api/rack', headers=AUTH).get_json()['items'][0]['chassis'] == 'unvr-pro'
def test_shelf_lists_what_is_on_it(self, client):
self._items({'name': 'Shelf', 'kind': 'shelf', 'rack_u': 3, 'rack_height': 1},
{'name': 'large1', 'kind': 'device', 'shelf': 'Shelf'},
+13 -1
View File
@@ -270,7 +270,7 @@ class TestRackLayout:
def test_past_the_top(self):
out = cs.rack_layout([self._item('tall', 41, 3)], 42)
assert out['placed'] == [] and out['outside'] == [{'name': 'tall', 'units': 'U41–43'}]
assert out['placed'] == [] and out['outside'] == [{'name': 'tall', 'units': 'U41-43'}]
class TestPortMarks:
@@ -357,3 +357,15 @@ class TestRackFaces:
for bad in ({'name': '', 'units': 25}, {'name': 'x'}, {'name': 'x', 'units': 61}, {'name': 'x', 'units': 0}):
with pytest.raises(cs.ValidationError):
cs.validate_rack(bad)
class TestChassis:
def test_known_drawing_kept_unknown_refused(self):
assert cs.validate_item({'name': 'nvr', 'chassis': 'unvr-pro'})['chassis'] == 'unvr-pro'
assert cs.validate_item({'name': 'x'})['chassis'] == ''
with pytest.raises(cs.ValidationError):
cs.validate_item({'name': 'x', 'chassis': 'tardis'})
def test_every_drawing_has_a_label(self):
assert all(cs.CHASSIS.values())
assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24'} <= set(cs.CHASSIS)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Tests for gandalf.diagnose — all pure static methods, no external deps."""
"""Tests for gandalf.diagnose: all pure static methods, no external deps."""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))