diff --git a/.flake8 b/.flake8 index 5a5abd8..50b9f61 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1,7 @@ [flake8] max-line-length = 120 -# E221: multiple spaces before operator — intentional column alignment -# E305: two blank lines after function — relaxed for module-level code +# E221: multiple spaces before operator: intentional column alignment +# E305: two blank lines after function: relaxed for module-level code extend-ignore = E221, E305 exclude = __pycache__, .git, node_modules extend-exclude = node_modules diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 20af6e9..4633bcf 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -50,7 +50,7 @@ jobs: if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi curl -sf -X POST "$MATRIX_WEBHOOK_URL" \ -H "Content-Type: application/json" \ - -d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}" + -d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH}: ${RUN_URL}\"}" deploy: name: Deploy diff --git a/README.md b/README.md index 58a605c..43906fa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Network monitoring dashboard for the LotusGuild Proxmox cluster. Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`. -**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps +**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template): shared CSS, JS, and layout patterns for all LotusGuild apps ![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`, diff --git a/app.py b/app.py index 5ea1d2e..e110718 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -"""Gandalf – Global Advanced Network Detection And Link Facilitator. +"""Gandalf: Global Advanced Network Detection And Link Facilitator. Flask web application serving the monitoring dashboard and suppression management UI. Authentication via Authelia forward-auth headers. @@ -63,7 +63,7 @@ def inject_config(): # In-memory diagnostic job store { job_id: { status, result, created_at } } _diag_jobs: dict = {} _diag_lock = threading.Lock() -# Per-user rate-limit: { username: [epoch_float, ...] } — cleaned inside _diag_lock +# Per-user rate-limit: { username: [epoch_float, ...] }: cleaned inside _diag_lock _diag_rate: dict = {} @@ -80,7 +80,7 @@ def _purge_old_jobs_loop(): for jid, j in list(_diag_jobs.items()): if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff: j['status'] = 'done' - j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned — no activity for 5 minutes.'} + j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned: no activity for 5 minutes.'} logger.error(f'Diagnostic job {jid} stuck (no activity for 5 min); marked done/error') @@ -201,7 +201,7 @@ def require_auth(f): f'only auth.trusted_proxies may send them' ) return ( - '

401 – Not authenticated

' + '

401: Not authenticated

' '

Please access Gandalf through ' 'auth.lotusguild.org.

', 401, @@ -211,7 +211,7 @@ def require_auth(f): safe_user = html.escape(user['username']) safe_groups = html.escape(', '.join(allowed)) return ( - f'

403 – Access denied

' + f'

403: Access denied

' f'

Your account ({safe_user}) is not in an allowed group ' f'({safe_groups}).

', 403, @@ -602,7 +602,7 @@ def api_create_suppression(): ) logger.info( f'Suppression #{sup_id} created by {user["username"]}: ' - f'{target_type}/{target_name}/{target_detail} – {reason}' + f'{target_type}/{target_name}/{target_detail}: {reason}' ) return jsonify({'success': True, 'id': sup_id}) @@ -814,7 +814,7 @@ def api_avatar(): # Query lldap bind_pw = ldap_cfg.get('bind_pw', '') if not bind_pw: - logger.error('LDAP bind_pw not configured — avatar lookup disabled') + logger.error('LDAP bind_pw not configured: avatar lookup disabled') return '', 404 avatar_data = None @@ -832,7 +832,7 @@ def api_avatar(): if conn.entries and conn.entries[0]['avatar'].value: avatar_data = conn.entries[0]['avatar'].value except ImportError: - logger.error('ldap3 not installed — run: pip install ldap3') + logger.error('ldap3 not installed; run pip install ldap3') return '', 404 except Exception as e: logger.error(f'LDAP avatar lookup failed for {username}: {e}') @@ -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 diff --git a/cutsheet.py b/cutsheet.py index fd04604..645b121 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -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 diff --git a/diagnose.py b/diagnose.py index 594bfdb..c8d6719 100644 --- a/diagnose.py +++ b/diagnose.py @@ -1,4 +1,4 @@ -"""Gandalf – Link Diagnostics module. +"""Gandalf: Link Diagnostics module. Runs a comprehensive SSH-based diagnostic against a server NIC and analyses the result against switch port data to surface root causes. @@ -475,7 +475,7 @@ class DiagnosticsRunner: # Physical carrier if carrier == '0': add(issues, 'NO_CARRIER', - 'No physical carrier — cable/SFP disconnected or switch port disabled') + 'No physical carrier: cable/SFP disconnected or switch port disabled') elif eth.get('link_detected') is False and carrier != '0': add(issues, 'LINK_NOT_DETECTED', 'NIC does not detect link signal despite carrier sysfs reading non-zero') @@ -483,7 +483,7 @@ class DiagnosticsRunner: # Duplex if eth.get('duplex') == 'half': add(issues, 'HALF_DUPLEX', - 'Half-duplex detected — likely duplex mismatch; force full-duplex on both ends') + 'Half-duplex detected: likely duplex mismatch; force full-duplex on both ends') # Speed mismatch (switch vs server NIC) sw_speed = switch_port_data.get('speed_mbps', 0) or 0 @@ -498,31 +498,31 @@ class DiagnosticsRunner: if rx_dbm is not None: if rx_dbm < -25: add(issues, 'SFP_RX_CRITICAL', - f'RX power critically low ({rx_dbm:.2f} dBm) — fiber not connected or SFP failed') + f'RX power critically low ({rx_dbm:.2f} dBm): fiber not connected or SFP failed') elif rx_dbm < -18: add(warnings, 'SFP_RX_LOW', - f'RX power low ({rx_dbm:.2f} dBm) — check fiber cleanliness and SFP seating') + f'RX power low ({rx_dbm:.2f} dBm): check fiber cleanliness and SFP seating') if tx_dbm is not None and tx_dbm < -10: add(warnings, 'SFP_TX_LOW', - f'TX power low ({tx_dbm:.2f} dBm) — SFP may be failing or requires cleaning') + f'TX power low ({tx_dbm:.2f} dBm): SFP may be failing or requires cleaning') # Carrier changes (flapping) if cc is not None: if cc > 100: add(issues, 'CARRIER_FLAPPING', - f'Link has flapped {cc} times — severe physical instability') + f'Link has flapped {cc} times: severe physical instability') elif cc > 20: add(warnings, 'CARRIER_FLAPS', - f'Link has flapped {cc} times — intermittent physical issue') + f'Link has flapped {cc} times: intermittent physical issue') # CRC errors crc = sysfs.get('rx_crc_errors', 0) or 0 if crc > 100: add(issues, 'CRC_ERRORS_HIGH', - f'High CRC error count ({crc}) — dirty fiber/connector or cable damage') + f'High CRC error count ({crc}): dirty fiber/connector or cable damage') elif crc > 10: add(warnings, 'CRC_ERRORS_LOW', - f'CRC errors present ({crc}) — cable or SFP quality issue') + f'CRC errors present ({crc}): cable or SFP quality issue') # Kernel events err_events = [e for e in dmesg if e['severity'] == 'error'] @@ -540,9 +540,9 @@ class DiagnosticsRunner: if expected_switch and srv_neighbor and names_differ: add(warnings, 'LLDP_MISMATCH', f'LLDP mismatch: this port is on "{switch_port_data.get("switch_name")}" but the server\'s ' - f'lldpctl sees "{lldp.get("neighbor_system")}" — cross-cabled port?') + f'lldpctl sees "{lldp.get("neighbor_system")}": cross-cabled port?') else: add(info, 'LLDP_MISSING', - 'lldpd not running on server — install lldpd for full path validation') + 'lldpd not running on server: install lldpd for full path validation') return {'issues': issues, 'warnings': warnings, 'info': info} diff --git a/monitor.py b/monitor.py index cf46eb5..6894ece 100644 --- a/monitor.py +++ b/monitor.py @@ -427,7 +427,7 @@ class TicketClient: def create(self, title: str, description: str, priority: str = '2') -> Optional[str]: if not self.api_key or not self.url: - logger.warning('Ticket API not configured – skipping ticket creation') + logger.warning('Ticket API not configured: skipping ticket creation') return None try: resp = requests.post( @@ -453,7 +453,7 @@ class TicketClient: logger.info(f'Created ticket #{tid}: {title}') return tid if data.get('existing_ticket_id'): - logger.info(f'Duplicate suppressed by API – existing #{data["existing_ticket_id"]}') + logger.info(f'Duplicate suppressed by API: existing #{data["existing_ticket_id"]}') return data['existing_ticket_id'] logger.warning(f'Unexpected ticket API response: {data}') except Exception as e: @@ -814,7 +814,7 @@ class LinkStatsCollector: host = instance_map.get(instance, host_ip) ifaces = list(iface_metrics.keys()) - # SSH ethtool collection via Pulse worker — only for explicitly configured + # SSH ethtool collection via Pulse worker: only for explicitly configured # hosts (instance_map keys). Hosts like postgresql/matrix may report # node_exporter metrics to Prometheus but don't need link diagnostics. ethtool_data: Dict[str, dict] = {} @@ -1213,11 +1213,11 @@ class NetworkMonitor: db.resolve_event('interface_down', host, iface) else: if prev is None: - # First observation is down – could be unused port, don't alert + # First observation is down: could be unused port, don't alert new_baseline[host][iface] = 'initial_down' elif prev == 'initial_down': - # Persistently down since first observation – no alert + # Persistently down since first observation: no alert pass else: # prev == 'up' @@ -1256,7 +1256,7 @@ class NetworkMonitor: db.set_baseline(new_baseline) - # Cluster-wide check – only genuine regressions count + # Cluster-wide check: only genuine regressions count if len(hosts_with_regression) >= self.cluster_thresh: sup = db.check_suppressed(suppressions, 'all', '') event_id, is_new, consec, ticket_id = db.upsert_event( @@ -1824,7 +1824,7 @@ class NetworkMonitor: # ------------------------------------------------------------------ def run(self) -> None: logger.info( - f'Gandalf monitor started – poll_interval={self.poll_interval}s ' + f'Gandalf monitor started: poll_interval={self.poll_interval}s ' f'fail_thresh={self.fail_thresh} host_fail_thresh={self.host_fail_thresh} ' f'unifi_fail_thresh={self.unifi_fail_thresh}' ) @@ -1832,13 +1832,13 @@ class NetworkMonitor: try: logger.info('Starting network check cycle') - # 1. Fetch interface states once — shared by snapshot and alert processing + # 1. Fetch interface states once: shared by snapshot and alert processing iface_states = self.prom.get_interface_states() - # 2. Fetch UniFi devices once — used by both snapshot and alert processing + # 2. Fetch UniFi devices once: used by both snapshot and alert processing unifi_devices = self.unifi.get_devices() - # 3a. Ping-only hosts once — shared by snapshot and alert processing + # 3a. Ping-only hosts once: shared by snapshot and alert processing ping_states: Dict[str, Optional[bool]] = { h['name']: self.pulse.ping(h['ip']) for h in self.cfg.get('monitor', {}).get('ping_hosts', []) diff --git a/schema.sql b/schema.sql index fbfc6cf..0b6a8bd 100644 --- a/schema.sql +++ b/schema.sql @@ -1,4 +1,4 @@ --- Gandalf Network Monitor – Database Schema +-- Gandalf Network Monitor: Database Schema -- Run on MariaDB LXC 149 (10.10.10.50) CREATE DATABASE IF NOT EXISTS gandalf diff --git a/static/app.js b/static/app.js index 53b8da0..b586c4f 100644 --- a/static/app.js +++ b/static/app.js @@ -11,13 +11,13 @@ const resp = await _fetch(...args); if (resp.status === 401) { window.location.reload(); - throw new Error('Session expired — reloading'); + throw new Error('Session expired, reloading'); } return resp; }; })(); -// ── Toast notifications — thin wrapper over lt.toast ────────────────── +// ── Toast notifications: thin wrapper over lt.toast ────────────────── function showToast(msg, type = 'success') { if (type === 'error') return lt.toast.error(msg); if (type === 'warning') return lt.toast.warning(msg); @@ -116,7 +116,7 @@ function updateStatusBar(summary, lastCheck, daemonOk) { } const mins = Math.floor(checkAge / 60); staleBanner.querySelector('.lt-alert-msg').textContent = - `Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`; + `Monitoring data is stale: last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`; staleBanner.style.display = ''; } else if (staleBanner) { staleBanner.style.display = 'none'; @@ -275,7 +275,7 @@ async function refreshModem() { } function _modemUptime(seconds) { - if (seconds === null || seconds === undefined) return '–'; + if (seconds === null || seconds === undefined) return '-'; const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); @@ -283,7 +283,7 @@ function _modemUptime(seconds) { } function _modemRate(bytesPerSec) { - if (bytesPerSec === null || bytesPerSec === undefined) return '–'; + if (bytesPerSec === null || bytesPerSec === undefined) return '-'; const mbps = bytesPerSec * 8 / 1e6; return mbps >= 1000 ? `${(mbps / 1000).toFixed(2)} Gbps` : `${mbps.toFixed(1)} Mbps`; } @@ -298,7 +298,7 @@ function _modemStat(label, value, cls, note) { } function _modemOptic(label, metric, status, unit) { - if (!metric || metric.value === null || metric.value === undefined) return _modemStat(label, '–'); + if (!metric || metric.value === null || metric.value === undefined) return _modemStat(label, '-'); const cls = { alarm: 'lt-text-red', warning: 'lt-text-amber', ok: 'lt-text-green' }[status] || ''; return _modemStat(label, `${metric.value} ${unit}`, cls, `warn ${metric.warn_low} to ${metric.warn_high}`); } @@ -391,7 +391,7 @@ function updateModem(data) { if (topo) topo.classList.add('topo-v2-status-down'); body.innerHTML = `
Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')} - Last attempt ${lt.escHtml(data.updated || '–')}
+ Last attempt ${lt.escHtml(data.updated || '-')} ${_modemChart(data.history)}`; return; } @@ -404,7 +404,7 @@ function updateModem(data) { const rx = optics.rx_power ? optics.rx_power.value : null; if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0); - if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx –' : `Rx ${rx} dBm`; + if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx -' : `Rx ${rx} dBm`; if (title) { title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`] .filter(Boolean).join(' · '); @@ -417,7 +417,7 @@ function updateModem(data) {
${_modemStat('WAN', problems.length ? 'DOWN' : 'UP', problems.length ? 'lt-text-red' : 'lt-text-green', data.wan_ip || '')} - ${_modemStat('PON', data.pon_state || '–', '', data.wavelength || '')} + ${_modemStat('PON', data.pon_state || '-', '', data.wavelength || '')} ${_modemOptic('Rx power', optics.rx_power, status.rx_power, 'dBm')} ${_modemOptic('Tx power', optics.tx_power, status.tx_power, 'dBm')} ${_modemOptic('Optics temp', optics.temperature, status.temperature, '°C')} @@ -427,7 +427,7 @@ function updateModem(data) {
${_modemChart(data.history)} ${_modemOutages(data.wan_history)} -
Updated ${lt.escHtml(data.updated || '–')}
`; +
Updated ${lt.escHtml(data.updated || '-')}
`; } function updateUnifiTable(devices) { @@ -481,7 +481,7 @@ function updateEventsTable(events, totalActive) { const ticket = e.ticket_id ? `#${lt.escHtml(String(e.ticket_id))}` - : '–'; + : '-'; const supBadge = e.is_suppressed ? `🔕 sup` : ''; @@ -490,7 +490,7 @@ function updateEventsTable(events, totalActive) { ${e.severity}${supBadge} ${lt.escHtml(e.event_type.replace(/_/g,' '))} ${lt.escHtml(e.target_name)} - ${lt.escHtml(e.target_detail || '–')} + ${lt.escHtml(e.target_detail || '-')} ${lt.escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''} ${lt.time.ago(_toIso(e.first_seen))} ${lt.time.ago(_toIso(e.last_seen))} @@ -618,7 +618,7 @@ async function submitSuppress(e) { } } -// ── Suppress form – wired here so the modal works from any page ────── +// ── Suppress form: wired here so the modal works from any page ────── document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress); document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm); diff --git a/static/base.js b/static/base.js index cd20236..943e257 100644 --- a/static/base.js +++ b/static/base.js @@ -347,7 +347,7 @@ '[ OK ] Scheduled tasks registered', '[ OK ] Terminal interface rendered', '', - '> ALL SYSTEMS NOMINAL — ' + name, + '> ALL SYSTEMS NOMINAL: ' + name, '', ]; @@ -486,7 +486,7 @@ ---------------------------------------------------------------- */ function timeAgo(value) { const date = value instanceof Date ? value : new Date(value); - if (isNaN(date)) return '—'; + if (isNaN(date)) return '-'; const diff = Math.floor((Date.now() - date.getTime()) / 1000); if (diff < 60) return diff + 's ago'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; @@ -505,7 +505,7 @@ function formatDate(value) { const date = value instanceof Date ? value : new Date(value); - if (isNaN(date)) return '—'; + if (isNaN(date)) return '-'; try { return date.toLocaleString(undefined, { timeZone: global.APP_TIMEZONE || undefined, @@ -522,7 +522,7 @@ lt.bytes.format(n) ---------------------------------------------------------------- */ function formatBytes(bytes) { - if (bytes == null) return '—'; + if (bytes == null) return '-'; const units = ['B','KB','MB','GB','TB']; let i = 0; while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; } return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i]; @@ -1137,9 +1137,9 @@ lt.num.format / compact / percent / pad / clamp / lerp ---------------------------------------------------------------- */ const num = { - format(n, d) { if (n == null || isNaN(n)) return '—'; return Number(n).toLocaleString(undefined, { minimumFractionDigits: d || 0, maximumFractionDigits: d != null ? d : 2 }); }, + format(n, d) { if (n == null || isNaN(n)) return '-'; return Number(n).toLocaleString(undefined, { minimumFractionDigits: d || 0, maximumFractionDigits: d != null ? d : 2 }); }, compact(n) { if (Math.abs(n) >= 1e9) return (n/1e9).toFixed(1)+'B'; if (Math.abs(n) >= 1e6) return (n/1e6).toFixed(1)+'M'; if (Math.abs(n) >= 1e3) return (n/1e3).toFixed(1)+'K'; return String(n); }, - percent(n, total) { return total ? ((n/total)*100).toFixed(1)+'%' : '—'; }, + percent(n, total) { return total ? ((n/total)*100).toFixed(1)+'%' : '-'; }, pad(n, w) { return String(Math.floor(n)).padStart(w, '0'); }, clamp(n, a, b) { return Math.min(b, Math.max(a, n)); }, lerp(a, b, t) { return a + (b - a) * t; }, @@ -1759,7 +1759,7 @@ _offlineBanner.className = 'lt-offline-banner'; _offlineBanner.setAttribute('role', 'alert'); _offlineBanner.setAttribute('aria-live', 'assertive'); - _offlineBanner.innerHTML = ' NO NETWORK CONNECTION — RECONNECTING...'; + _offlineBanner.innerHTML = ' NO NETWORK CONNECTION, RECONNECTING...'; document.body.appendChild(_offlineBanner); } } @@ -1770,7 +1770,7 @@ window.addEventListener('offline', () => { document.body.classList.add('lt-is-offline'); _offlineGetBanner().classList.add('is-visible'); - toast.warning('Connection lost — working offline'); + toast.warning('Connection lost, working offline'); _offlineHandlers.forEach(fn => fn()); }); window.addEventListener('online', () => { diff --git a/static/cutsheet.js b/static/cutsheet.js index 330789f..db73a57 100644 --- a/static/cutsheet.js +++ b/static/cutsheet.js @@ -112,10 +112,10 @@ function renderCables() { IDFromToTypeStatusCheck${CS_CAN_EDIT ? '' : ''} ${rows.map(c => ` - ${c.label ? esc(c.label) : '–'}${c.label && !c.label_applied && c.status !== 'retired' ? ' not stuck on' : ''} + ${c.label ? esc(c.label) : '-'}${c.label && !c.label_applied && c.status !== 'retired' ? ' not stuck on' : ''} ${endText(c, 'a')} ${endText(c, 'b')}${pathText(c) ? `
${pathText(c)}
` : ''} - ${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '–'} + ${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '-'} ${esc(c.status)} ${verdictBadge(c)}
${esc(c.detail)}
${CS_CAN_EDIT ? `` : ''} @@ -180,11 +180,11 @@ function renderPanels() { const lab = labels[`${p.id}|${n}`]; const cell = (c, side) => c ? `${esc(c.label || 'unlabeled')} → ${farEnd(c, p.name, n, side)}` - : '–'; + : '-'; rows.push(` ${n} - ${lab ? esc(lab.label) + (lab.applied ? '' : ' not stuck on') : '–'} + ${lab ? esc(lab.label) + (lab.applied ? '' : ' not stuck on') : '-'} ${CS_CAN_EDIT ? `` : ''} ${cell(front, 'front')} ${cell(rear, 'rear')} @@ -249,7 +249,7 @@ function renderRack() { const top = i.rack_u + (i.rack_height || 1) - 1; return `
- ${i.rack_height > 1 ? `U${esc(top)}–${esc(i.rack_u)}` : `U${esc(i.rack_u)}`} + ${i.rack_height > 1 ? `U${esc(top)}-${esc(i.rack_u)}` : `U${esc(i.rack_u)}`} ${deviceLink(i.name)} ${esc(KIND_TEXT[i.kind] || i.kind)}
`; @@ -259,7 +259,7 @@ function renderRack() { function rackText(i) { if (i.shelf) return `on ${esc(i.shelf)}`; - if (!i.rack_u) return '–'; + if (!i.rack_u) return '-'; const side = { rear: ' rear', full: ' full depth' }[i.mount] || ''; const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}${side}`; return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where; @@ -282,9 +282,9 @@ function renderItems() { ${deviceLink(i.name)} ${esc(KIND_TEXT[i.kind] || i.kind)} - ${esc(i.port_count || '–')} + ${esc(i.port_count || '-')} ${rackText(i)} - ${esc(i.location || '–')} + ${esc(i.location || '-')} ${esc(i.notes || '')} ${CS_CAN_EDIT ? `` : ''} `).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 => ``).join(''); + form.cable_type.innerHTML = types.map(t => ``).join(''); const data = Object.assign({ status: 'installed', label: cable ? '' : _cs.next_label }, cable || {}, prefill || {}); for (const key of ['id', 'label', 'a_device', 'a_port', 'b_device', 'b_port', 'cable_type', 'length_m', 'color', 'status', 'notes']) { if (form[key]) form[key].value = data[key] == null ? '' : data[key]; diff --git a/static/device.js b/static/device.js index 4e95d42..78dd75f 100644 --- a/static/device.js +++ b/static/device.js @@ -13,16 +13,16 @@ let _device = null; // ── Formatting ─────────────────────────────────────────────────────── function fmtSpeed(mbps) { - if (!mbps) return '–'; + if (!mbps) return '-'; return mbps >= 1000 ? (mbps / 1000).toFixed(0) + ' Gbps' : mbps + ' Mbps'; } function fmtUnit(value, unit, digits) { - return value === null || value === undefined ? '–' : `${value.toFixed(digits)} ${unit}`; + return value === null || value === undefined ? '-' : `${value.toFixed(digits)} ${unit}`; } function fmtUptime(seconds) { - if (seconds === null || seconds === undefined) return '–'; + if (seconds === null || seconds === undefined) return '-'; const d = Math.floor(seconds / 86400), h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); return d ? `${d}d ${h}h` : `${h}h ${m}m`; @@ -108,7 +108,7 @@ function sfpPanel(s) {
${vendor}
${item('Temp', tempClass(s.temp_c), fmtUnit(s.temp_c, '°C', 1), 'Module temperature. Normal: below 70°C.')} - ${item('Voltage', voltClass(s.voltage_v), fmtUnit(s.voltage_v, 'V', 2), 'Supply voltage. Normal: 3.1–3.5 V.')} + ${item('Voltage', voltClass(s.voltage_v), fmtUnit(s.voltage_v, 'V', 2), 'Supply voltage. Normal: 3.1-3.5 V.')} ${item('Bias', '', fmtUnit(s.bias_ma, 'mA', 2), 'Laser bias current. High values may mean an aging laser.')} ${item('TX Power', tx, fmtUnit(s.tx_power_dbm, 'dBm', 2), 'Optical transmit power.', txPct)} ${item('RX Power', rx, fmtUnit(s.rx_power_dbm, 'dBm', 2), 'Optical receive power. Very low values may mean dirty or damaged fiber.', rxPct)} @@ -127,9 +127,9 @@ function hostRows(interfaces) { id: name, label: name, up, data: d, sub: d.port_type || '', detail: `