Remove em and en dashes from the UI, code and docs
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 57s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Python (flake8) (pull_request) Successful in 51s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 1m3s
Test / Python Tests (pytest) (pull_request) Successful in 52s

Page titles read "Rack | GANDALF", ranges use a hyphen (U26-42,
1-100 of 300), empty cells show "-", and dash-separated phrases use a
colon, comma or full stop. In the shared base.js only the on-screen strings
changed (boot line, placeholders, offline banner and toast).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 23:37:56 -05:00
co-authored by Claude Opus 5.5
parent 3c9e5fd593
commit 619a2c913d
22 changed files with 160 additions and 160 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
+17 -17
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) |
@@ -374,7 +374,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.
---
@@ -470,7 +470,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. |
@@ -485,7 +485,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;
@@ -499,7 +499,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
@@ -746,7 +746,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`.
@@ -759,13 +759,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).
@@ -777,11 +777,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`,
+9 -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}')
@@ -1416,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
+1 -1
View File
@@ -617,7 +617,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
+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', () => {
+9 -9
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];
+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>` : '';
}
+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>` : ''}
+1 -1
View File
@@ -152,7 +152,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>`;
}
+6 -6
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 {
+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');
+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;
+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>
+2 -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)
+1 -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:
+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__)))