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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 — 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
@@ -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&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 @@
|
||||
·
|
||||
<span class="lt-badge badge-resolved">{{ e.severity }}</span>
|
||||
· 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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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__)))
|
||||
|
||||
Reference in New Issue
Block a user