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  @@ -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 ( - '
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'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 = `| #${esc(o.index)} | -${esc(o.name || '–')} | -${o.power_w === null || o.power_w === undefined ? '–' : `${o.power_w.toFixed(1)} W`} | +${esc(o.name || '-')} | +${o.power_w === null || o.power_w === undefined ? '-' : `${o.power_w.toFixed(1)} W`} | ${o.auto_power_cycle ? 'auto power-cycle' : ''} |
| ${esc(e.severity)}${e.is_suppressed && !e.resolved_at ? '🔕 sup' : ''} | ${esc(e.event_type.replace(/_/g, ' '))} | -${esc(e.target_detail || '–')} | +${esc(e.target_detail || '-')} | ${esc(e.description || '')} | ${esc(lt.time.ago(_toIso(e.first_seen)))} | ${e.resolved_at ? `resolved ${esc(lt.time.ago(_toIso(e.resolved_at)))}` : 'active'} | diff --git a/static/events.js b/static/events.js index aaae2e0..c51a6e4 100644 --- a/static/events.js +++ b/static/events.js @@ -28,7 +28,7 @@ function duration(seconds) { } function when(iso) { - if (!iso) return '–'; + if (!iso) return '-'; const d = new Date(_toIso(iso)); if (isNaN(d)) return esc(iso); const text = d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); @@ -36,7 +36,7 @@ function when(iso) { } function ticketLink(id) { - if (!id) return '–'; + if (!id) return '-'; const base = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url) || ''; return base ? `#${esc(id)}` : `#${esc(id)}`; } @@ -157,7 +157,7 @@ function renderTable(data) { const next = end < data.total; document.getElementById('ev-pager').innerHTML = (prev || next) ? ` - ${data.offset + 1}–${end} of ${data.total} + ${data.offset + 1}-${end} of ${data.total} ` : ''; } diff --git a/static/port-inspector.js b/static/port-inspector.js index 1af6443..f55dacc 100644 --- a/static/port-inspector.js +++ b/static/port-inspector.js @@ -9,13 +9,13 @@ const escHtml = s => lt.escHtml(s); // ── Formatting helpers ─────────────────────────────────────────────────── function fmtShortSpeed(mbps) { - if (!mbps) return '–'; + if (!mbps) return '-'; if (mbps >= 1000) return (mbps / 1000).toFixed(0) + 'G'; return mbps + 'M'; } function fmtRate(bytesPerSec) { - if (bytesPerSec === null || bytesPerSec === undefined) return '–'; + if (bytesPerSec === null || bytesPerSec === undefined) return '-'; const bps = bytesPerSec * 8; if (bps < 1e3) return bps.toFixed(0) + ' bps'; if (bps < 1e6) return (bps / 1e3).toFixed(1) + ' Kbps'; @@ -24,7 +24,7 @@ function fmtRate(bytesPerSec) { } function fmtErrors(rate) { - if (rate === null || rate === undefined) return '–'; + if (rate === null || rate === undefined) return '-'; if (rate < 0.001) return '0 /s'; return `${rate.toFixed(3)} /s`; } @@ -298,11 +298,11 @@ function renderPanel(swName, idx) { if (!d) return; const upStr = d.up ? 'UP' : 'DOWN'; - const speedStr = d.speed_mbps ? `${fmtShortSpeed(d.speed_mbps)}bps` : '–'; + const speedStr = d.speed_mbps ? `${fmtShortSpeed(d.speed_mbps)}bps` : '-'; const duplexCls = d.full_duplex ? 'val-good' : (d.up ? 'val-warn' : 'val-neutral'); - const duplexStr = d.up ? `${d.full_duplex ? 'Full' : 'Half'}` : '–'; + const duplexStr = d.up ? `${d.full_duplex ? 'Full' : 'Half'}` : '-'; const autoneg = d.autoneg ? 'On' : 'Off'; - const mediaStr = d.media || '–'; + const mediaStr = d.media || '-'; const isUplinkBadge = d.is_uplink ? ' UPLINK' : ''; // PoE section @@ -321,8 +321,8 @@ function renderPanel(swName, idx) { if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) { trafficHtml = `||||||||||||
| RX Pause | ${pause.rx_pause ? 'On' : 'Off'} | |||||||||||||||||
| TX Pause | ${pause.tx_pause ? 'On' : 'Off'} | |||||||||||||||||
| RX Ring | ${ring.rx_current != null ? ring.rx_current : '–'} / ${ring.rx_max != null ? ring.rx_max : '–'} max | |||||||||||||||||
| TX Ring | ${ring.tx_current != null ? ring.tx_current : '–'} / ${ring.tx_max != null ? ring.tx_max : '–'} max | |||||||||||||||||
| RX Ring | ${ring.rx_current != null ? ring.rx_current : '-'} / ${ring.rx_max != null ? ring.rx_max : '-'} max | |||||||||||||||||
| TX Ring | ${ring.tx_current != null ? ring.tx_current : '-'} / ${ring.tx_max != null ? ring.tx_max : '-'} max |
| Status | ${swPort.up ? 'UP' : 'DOWN'} |
| Speed | ${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '–'} |
| Duplex | ${swPort.full_duplex ? 'Full' : (swPort.up ? 'Half' : '–')} |
| Speed | ${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '-'} |
| Duplex | ${swPort.full_duplex ? 'Full' : (swPort.up ? 'Half' : '-')} |
| TX Err | ${fmtErrors(swPort.tx_errs_rate)} |
| RX Err | ${fmtErrors(swPort.rx_errs_rate)} |
| PoE | ${swPort.poe_power.toFixed(1)}W |