Merge pull request 'Rack: drawn fronts (NVR Pro, UACC panel, server cases), faceplate labels, no em/en dashes' (#27) from rack-drawn-fronts into main
Lint / Python (flake8) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 10s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 2m0s
Test / Python Tests (pytest) (push) Successful in 2m4s
Lint / Deploy (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 10s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 2m0s
Test / Python Tests (pytest) (push) Successful in 2m4s
Lint / Deploy (push) Successful in 8s
This commit was merged in pull request #27.
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -133,6 +133,12 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
|
||||
patch panels get a generic panel with each port's label; anything else is a labelled plate with its
|
||||
live status. Cables may use either name: checks, suggestions, tickets and the device page treat an
|
||||
item and its device as the same thing.
|
||||
- **Drawn fronts**: gear without a live faceplate can pick a **Front drawing** on its cutsheet item:
|
||||
UniFi NVR Pro (a full faceplate, `UNVRPRO` in `static/faceplates.js`, laid out from the CC0 NetBox
|
||||
device library image), Sliger CX4712 4U (10 numbered bays), Rosewill RSV-L4500U 4U (perforated fan
|
||||
door), the UniFi blank 24-port keystone patch panel (UACC-Rack-Panel-Patch-Blank-24: keystone jacks
|
||||
appear where cables are documented), or a generic 1U/2U/4U server. The power light shows the linked device's live status. Case
|
||||
drawings live in `static/fronts.js`.
|
||||
- **Shelves**: an item can sit on a shelf item instead of having a rack unit. The shelf draws what is on
|
||||
it side by side (mini PCs, desk switches), each with its status and a link to its page.
|
||||
- **Front and rear**: each item is mounted from the front, the rear, or full depth (both sides). On a
|
||||
@@ -369,7 +375,7 @@ generate tickets. Baseline is stored in MariaDB and survives daemon restarts.
|
||||
| `all` | Everything (global maintenance mode) |
|
||||
|
||||
Suppressions can be manual (persist until removed) or timed (auto-expire).
|
||||
Expired suppressions are checked at evaluation time — no background cleanup needed.
|
||||
Expired suppressions are checked at evaluation time: no background cleanup needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -465,7 +471,7 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/
|
||||
| `monitor.poll_interval` | Seconds between full check cycles (default: 120) |
|
||||
| `monitor.failure_threshold` | Consecutive failures before creating ticket (default: 2) |
|
||||
| `monitor.host_failure_threshold` | Consecutive failures before ticketing a host that has disappeared, whether it has no metrics or doesn't answer ping (default: 5). A normal reboot takes about 3-5 minutes, so the default rides it out: the host still shows as down on the dashboard, but a ticket opens only after about 8 minutes at the default poll interval. For longer maintenance, suppress the host first. |
|
||||
| `monitor.unifi_failure_threshold` | Consecutive failures before ticketing a UniFi device as offline (default: same as `failure_threshold`). Set higher than `failure_threshold` — UniFi switches/APs can take several minutes to rejoin after a firmware-update reboot, so a low threshold cuts tickets for momentary maintenance reboots instead of real failures. |
|
||||
| `monitor.unifi_failure_threshold` | Consecutive failures before ticketing a UniFi device as offline (default: same as `failure_threshold`). Set higher than `failure_threshold`: UniFi switches/APs can take several minutes to rejoin after a firmware-update reboot, so a low threshold cuts tickets for momentary maintenance reboots instead of real failures. |
|
||||
| `monitor.cluster_threshold` | Hosts with failures to trigger cluster-wide P1 (default: 3) |
|
||||
| `monitor.ping_hosts` | Hosts checked only by ping (no node_exporter) |
|
||||
| `cutsheet.mismatch_ticket_hours` | Hours a cutsheet mismatch must last before it opens a P4 ticket (default: 24; `0` never tickets). The mismatch shows on the dashboard and the switch's page straight away, so recabling never pages anyone. Suppressing the switch or its port also stops the ticket. |
|
||||
@@ -480,7 +486,7 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/
|
||||
|
||||
## Deployment (LXC 157)
|
||||
|
||||
### 1. Database — MariaDB LXC 149 (`10.10.10.50`)
|
||||
### 1. Database: MariaDB LXC 149 (`10.10.10.50`)
|
||||
|
||||
```sql
|
||||
CREATE DATABASE gandalf CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -494,7 +500,7 @@ Import schema:
|
||||
mysql -h 10.10.10.50 -u gandalf -p gandalf < schema.sql
|
||||
```
|
||||
|
||||
### 2. LXC 157 — Install dependencies
|
||||
### 2. LXC 157: Install dependencies
|
||||
|
||||
```bash
|
||||
pip3 install -r requirements.txt
|
||||
@@ -674,6 +680,7 @@ gandalf/
|
||||
│ ├── cutsheet.js # Cutsheet page
|
||||
│ ├── events.js # Alert history page
|
||||
│ ├── rack.js # Rack drawing
|
||||
│ ├── fronts.js # Drawn fronts for gear without a live faceplate (NVR, server cases)
|
||||
│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics
|
||||
└── templates/
|
||||
├── base.html # Shared layout (header, nav, footer)
|
||||
@@ -717,7 +724,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o
|
||||
|
||||
### Database Schema Notes
|
||||
|
||||
- `cutsheet_items` gained `rack`, `shelf`, `device` and `mount` columns; they are added automatically on first use
|
||||
- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount` and `chassis` columns; they are added automatically on first use
|
||||
- `cutsheet_racks`: racks saved on the rack page (name, height, notes)
|
||||
- `device_links`: each device's inventory URL, by device name; created with the cutsheet tables
|
||||
- `network_events`: one row per active event; `resolved_at` is set when recovered, and
|
||||
@@ -740,7 +747,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o
|
||||
- **Interface name validation**: `monitor.py` validates SSH interface names against
|
||||
`^[a-zA-Z0-9_.@-]+$` before use, and additionally wraps them with `shlex.quote()`
|
||||
for defense-in-depth.
|
||||
- **DB parameters**: all SQL uses parameterised queries via pymysql — no string
|
||||
- **DB parameters**: all SQL uses parameterised queries via pymysql: no string
|
||||
concatenation into SQL.
|
||||
- **Auth**: Authelia enforces admin-only access at the NPM/LXC 167 layer; the Flask
|
||||
app additionally checks the `Remote-User` and `Remote-Groups` headers via `@require_auth`.
|
||||
@@ -753,13 +760,13 @@ Take LED colors from the model's quick start guide. See the comment at the top o
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Single gunicorn worker (`--workers 1`) — required because `db.py` uses thread-local
|
||||
- Single gunicorn worker (`--workers 1`): required because `db.py` uses thread-local
|
||||
connection reuse (one connection per thread). Multiple workers would each have their
|
||||
own connection, which is fine, but the thread-local optimisation only helps within
|
||||
one worker.
|
||||
- No CSRF tokens on API endpoints — mitigated by Authelia session cookies being
|
||||
- No CSRF tokens on API endpoints: mitigated by Authelia session cookies being
|
||||
`SameSite=Strict` and the site being admin-only.
|
||||
- SSH collection via Pulse is synchronous — if Pulse is slow, the entire monitor cycle
|
||||
- SSH collection via Pulse is synchronous: if Pulse is slow, the entire monitor cycle
|
||||
is delayed. The `pulse.timeout` config controls the max wait.
|
||||
- UniFi LLDP data is only as fresh as the last monitor poll (120s default).
|
||||
|
||||
@@ -771,11 +778,11 @@ Take LED colors from the model's quick start guide. See the comment at the top o
|
||||
|---|---|---|
|
||||
| `lint.yml` (python-lint) | flake8 on all `.py` files | Every push and PR |
|
||||
| `lint.yml` (js-lint) | ESLint on `static/` | Every push and PR |
|
||||
| `test.yml` | pytest — `diagnose.py`, `modem.py`, and `monitor.py` alert logic | Every push and PR |
|
||||
| `test.yml` | pytest: `diagnose.py`, `modem.py`, and `monitor.py` alert logic | Every push and PR |
|
||||
| `security.yml` | bandit `-ll` (medium+ severity) | Every push, PR, and weekly Monday 6am |
|
||||
| `deploy` job in `lint.yml` | Calls the `gandalf-deploy` webhook on CT157 (10.10.10.61) | Push to `main` only, after both lint jobs pass |
|
||||
|
||||
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
||||
Branch protection is enabled on `main`: both lint jobs must pass before any PR can merge.
|
||||
|
||||
Tests live in `tests/`:
|
||||
- `test_diagnose.py`: `DiagnosticsRunner` static methods (`build_ssh_command`, `parse_output`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Gandalf – Global Advanced Network Detection And Link Facilitator.
|
||||
"""Gandalf: Global Advanced Network Detection And Link Facilitator.
|
||||
|
||||
Flask web application serving the monitoring dashboard and suppression
|
||||
management UI. Authentication via Authelia forward-auth headers.
|
||||
@@ -63,7 +63,7 @@ def inject_config():
|
||||
# In-memory diagnostic job store { job_id: { status, result, created_at } }
|
||||
_diag_jobs: dict = {}
|
||||
_diag_lock = threading.Lock()
|
||||
# Per-user rate-limit: { username: [epoch_float, ...] } — cleaned inside _diag_lock
|
||||
# Per-user rate-limit: { username: [epoch_float, ...] }: cleaned inside _diag_lock
|
||||
_diag_rate: dict = {}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ def _purge_old_jobs_loop():
|
||||
for jid, j in list(_diag_jobs.items()):
|
||||
if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff:
|
||||
j['status'] = 'done'
|
||||
j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned — no activity for 5 minutes.'}
|
||||
j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned: no activity for 5 minutes.'}
|
||||
logger.error(f'Diagnostic job {jid} stuck (no activity for 5 min); marked done/error')
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ def require_auth(f):
|
||||
f'only auth.trusted_proxies may send them'
|
||||
)
|
||||
return (
|
||||
'<h1>401 – Not authenticated</h1>'
|
||||
'<h1>401: Not authenticated</h1>'
|
||||
'<p>Please access Gandalf through '
|
||||
'<a href="https://auth.lotusguild.org">auth.lotusguild.org</a>.</p>',
|
||||
401,
|
||||
@@ -211,7 +211,7 @@ def require_auth(f):
|
||||
safe_user = html.escape(user['username'])
|
||||
safe_groups = html.escape(', '.join(allowed))
|
||||
return (
|
||||
f'<h1>403 – Access denied</h1>'
|
||||
f'<h1>403: Access denied</h1>'
|
||||
f'<p>Your account ({safe_user}) is not in an allowed group '
|
||||
f'({safe_groups}).</p>',
|
||||
403,
|
||||
@@ -602,7 +602,7 @@ def api_create_suppression():
|
||||
)
|
||||
logger.info(
|
||||
f'Suppression #{sup_id} created by {user["username"]}: '
|
||||
f'{target_type}/{target_name}/{target_detail} – {reason}'
|
||||
f'{target_type}/{target_name}/{target_detail}: {reason}'
|
||||
)
|
||||
return jsonify({'success': True, 'id': sup_id})
|
||||
|
||||
@@ -814,7 +814,7 @@ def api_avatar():
|
||||
# Query lldap
|
||||
bind_pw = ldap_cfg.get('bind_pw', '')
|
||||
if not bind_pw:
|
||||
logger.error('LDAP bind_pw not configured — avatar lookup disabled')
|
||||
logger.error('LDAP bind_pw not configured: avatar lookup disabled')
|
||||
return '', 404
|
||||
|
||||
avatar_data = None
|
||||
@@ -832,7 +832,7 @@ def api_avatar():
|
||||
if conn.entries and conn.entries[0]['avatar'].value:
|
||||
avatar_data = conn.entries[0]['avatar'].value
|
||||
except ImportError:
|
||||
logger.error('ldap3 not installed — run: pip install ldap3')
|
||||
logger.error('ldap3 not installed; run pip install ldap3')
|
||||
return '', 404
|
||||
except Exception as e:
|
||||
logger.error(f'LDAP avatar lookup failed for {username}: {e}')
|
||||
@@ -942,6 +942,7 @@ def _cutsheet_view() -> dict:
|
||||
data['devices'] = sorted(set(switches) | set(snapshot.get('hosts') or {})
|
||||
| {u['name'] for u in snapshot.get('unifi') or [] if u.get('name')}, key=str.lower)
|
||||
data['verdicts'] = cutsheet.VERDICTS
|
||||
data['chassis'] = cutsheet.CHASSIS
|
||||
data['options'] = {'kinds': cutsheet.ITEM_KINDS, 'passive_kinds': cutsheet.PASSIVE_KINDS,
|
||||
'statuses': cutsheet.STATUSES, 'cable_types': cutsheet.CABLE_TYPES}
|
||||
data['updated'] = link_stats.get('updated')
|
||||
@@ -1174,6 +1175,7 @@ def api_rack():
|
||||
entry['device'] = device
|
||||
entry['linked'] = device in known
|
||||
entry['mount'] = item.get('mount') or 'front'
|
||||
entry['chassis'] = item.get('chassis') or ''
|
||||
entry['through'] = item.get('through', False)
|
||||
entry['url'] = url(item)
|
||||
entry['status'] = _device_status(device, snapshot) if device in known else ''
|
||||
@@ -1414,4 +1416,4 @@ def health():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000) # nosec B201 B104 — dev runner only; production uses gunicorn
|
||||
app.run(debug=True, host='0.0.0.0', port=5000) # nosec B201 B104: dev runner only; production uses gunicorn
|
||||
|
||||
+14
-1
@@ -26,6 +26,16 @@ ITEM_KINDS = ('patch_panel', 'wall_jack', 'device', 'shelf', 'other')
|
||||
# a unit; a full-depth one (a server) takes the unit on both sides.
|
||||
MOUNTS = ('front', 'rear', 'full')
|
||||
RACK_UNITS_MAX = 60
|
||||
# Drawn fronts for gear without a live faceplate (keys of RACK_FRONTS in static/fronts.js)
|
||||
CHASSIS = {
|
||||
'unvr-pro': 'UniFi NVR Pro (UNVR Pro)',
|
||||
'uacc-patch-24': 'UniFi Patch Panel Blank 24 (UACC-Rack-Panel-Patch-Blank-24)',
|
||||
'sliger-cx4712': 'Sliger CX4712 4U (10-bay)',
|
||||
'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)',
|
||||
'server-1u': 'Generic 1U server',
|
||||
'server-2u': 'Generic 2U server',
|
||||
'server-4u': 'Generic 4U server',
|
||||
}
|
||||
PASSIVE_KINDS = ('patch_panel', 'wall_jack')
|
||||
SIDES = ('front', 'rear')
|
||||
STATUSES = ('planned', 'installed', 'dark', 'retired')
|
||||
@@ -99,9 +109,12 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
|
||||
'rack': _text(data, 'rack', RACK_NAME_MAX),
|
||||
'device': _text(data, 'device'),
|
||||
'mount': _text(data, 'mount', 10) or 'front',
|
||||
'chassis': _text(data, 'chassis', 40),
|
||||
'shelf': _text(data, 'shelf', NAME_MAX),
|
||||
'notes': _text(data, 'notes', NOTES_MAX),
|
||||
}
|
||||
if item['chassis'] and item['chassis'] not in CHASSIS:
|
||||
raise ValidationError(f'Unknown front drawing {item["chassis"]}')
|
||||
if item['mount'] not in MOUNTS:
|
||||
raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}')
|
||||
if item['shelf']:
|
||||
@@ -605,7 +618,7 @@ def rack_layout(items: List[dict], units: int, face: str = 'front') -> dict:
|
||||
for item in sorted(racked, key=lambda i: (-(i['rack_u'] + (i.get('rack_height') or 1)), i.get('id') or 0)):
|
||||
low = item['rack_u']
|
||||
high = low + (item.get('rack_height') or 1) - 1
|
||||
span = f'U{low}' if high == low else f'U{low}–{high}'
|
||||
span = f'U{low}' if high == low else f'U{low}-{high}'
|
||||
if high > units:
|
||||
outside.append({'name': item['name'], 'units': span})
|
||||
continue
|
||||
|
||||
@@ -678,6 +678,7 @@ _CUTSHEET_SCHEMA = (
|
||||
shelf VARCHAR(100) NOT NULL DEFAULT '',
|
||||
device VARCHAR(255) NOT NULL DEFAULT '',
|
||||
mount VARCHAR(10) NOT NULL DEFAULT 'front',
|
||||
chassis VARCHAR(40) NOT NULL DEFAULT '',
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -768,7 +769,7 @@ def set_device_link(name: str, inventory_url: str, user: str) -> None:
|
||||
)
|
||||
|
||||
_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'rack', 'shelf', 'device',
|
||||
'mount', 'notes')
|
||||
'mount', 'chassis', 'notes')
|
||||
# Columns added to cutsheet_items after its first release: (name, definition)
|
||||
_ITEM_COLUMNS_ADDED = (
|
||||
('rack', "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
@@ -776,6 +777,7 @@ _ITEM_COLUMNS_ADDED = (
|
||||
# The Gandalf device (UniFi or host name) an item is, when its own name differs
|
||||
('device', "VARCHAR(255) NOT NULL DEFAULT ''"),
|
||||
('mount', "VARCHAR(10) NOT NULL DEFAULT 'front'"),
|
||||
('chassis', "VARCHAR(40) NOT NULL DEFAULT ''"),
|
||||
)
|
||||
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
|
||||
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
|
||||
|
||||
+12
-12
@@ -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', () => {
|
||||
|
||||
+13
-11
@@ -112,10 +112,10 @@ function renderCables() {
|
||||
<thead><tr><th>ID</th><th>From</th><th>To</th><th>Type</th><th>Status</th><th>Check</th>${CS_CAN_EDIT ? '<th class="no-print"></th>' : ''}</tr></thead>
|
||||
<tbody>${rows.map(c => `
|
||||
<tr id="cable-${esc(c.id)}" class="${c.problem ? 'cs-row-problem' : ''}${c.status === 'retired' ? ' row-resolved' : ''}">
|
||||
<td class="cs-id">${c.label ? esc(c.label) : '<span class="val-neutral">–</span>'}${c.label && !c.label_applied && c.status !== 'retired' ? ' <span class="cs-tag" title="The label isn\'t on the cable yet">not stuck on</span>' : ''}</td>
|
||||
<td class="cs-id">${c.label ? esc(c.label) : '<span class="val-neutral">-</span>'}${c.label && !c.label_applied && c.status !== 'retired' ? ' <span class="cs-tag" title="The label isn\'t on the cable yet">not stuck on</span>' : ''}</td>
|
||||
<td data-label="From">${endText(c, 'a')}</td>
|
||||
<td data-label="To">${endText(c, 'b')}${pathText(c) ? `<div class="cs-path">${pathText(c)}</div>` : ''}</td>
|
||||
<td data-label="Type">${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '–'}</td>
|
||||
<td data-label="Type">${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '-'}</td>
|
||||
<td data-label="Status">${esc(c.status)}</td>
|
||||
<td data-label="Check">${verdictBadge(c)}<div class="cs-detail">${esc(c.detail)}</div></td>
|
||||
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-cable" data-id="${esc(c.id)}" aria-label="Edit cable ${esc(c.label || c.id)}">Edit</button></td>` : ''}
|
||||
@@ -180,11 +180,11 @@ function renderPanels() {
|
||||
const lab = labels[`${p.id}|${n}`];
|
||||
const cell = (c, side) => c
|
||||
? `<a class="cs-id" href="#cable-${esc(c.id)}">${esc(c.label || 'unlabeled')}</a> → ${farEnd(c, p.name, n, side)}`
|
||||
: '<span class="val-neutral">–</span>';
|
||||
: '<span class="val-neutral">-</span>';
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td class="cs-id">${n}</td>
|
||||
<td>${lab ? esc(lab.label) + (lab.applied ? '' : ' <span class="cs-tag">not stuck on</span>') : '<span class="val-neutral">–</span>'}
|
||||
<td>${lab ? esc(lab.label) + (lab.applied ? '' : ' <span class="cs-tag">not stuck on</span>') : '<span class="val-neutral">-</span>'}
|
||||
${CS_CAN_EDIT ? `<button type="button" class="cs-link-btn no-print" data-action="edit-port-label" data-item="${esc(p.id)}" data-port="${n}" aria-label="Edit label for ${esc(p.name)} port ${n}">edit</button>` : ''}</td>
|
||||
<td>${cell(front, 'front')}</td>
|
||||
<td>${cell(rear, 'rear')}</td>
|
||||
@@ -249,7 +249,7 @@ function renderRack() {
|
||||
const top = i.rack_u + (i.rack_height || 1) - 1;
|
||||
return `
|
||||
<div class="cs-rack-unit" style="--u:${Math.min(i.rack_height || 1, 8)}">
|
||||
<span class="cs-rack-u">${i.rack_height > 1 ? `U${esc(top)}–${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}</span>
|
||||
<span class="cs-rack-u">${i.rack_height > 1 ? `U${esc(top)}-${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}</span>
|
||||
<span class="cs-rack-name">${deviceLink(i.name)}</span>
|
||||
<span class="cs-rack-kind">${esc(KIND_TEXT[i.kind] || i.kind)}</span>
|
||||
</div>`;
|
||||
@@ -259,7 +259,7 @@ function renderRack() {
|
||||
|
||||
function rackText(i) {
|
||||
if (i.shelf) return `on ${esc(i.shelf)}`;
|
||||
if (!i.rack_u) return '–';
|
||||
if (!i.rack_u) return '-';
|
||||
const side = { rear: ' rear', full: ' full depth' }[i.mount] || '';
|
||||
const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}${side}`;
|
||||
return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where;
|
||||
@@ -282,9 +282,9 @@ function renderItems() {
|
||||
<tr id="${i.kind === 'patch_panel' ? '' : `item-${esc(i.id)}`}">
|
||||
<td>${deviceLink(i.name)}</td>
|
||||
<td>${esc(KIND_TEXT[i.kind] || i.kind)}</td>
|
||||
<td>${esc(i.port_count || '–')}</td>
|
||||
<td>${esc(i.port_count || '-')}</td>
|
||||
<td>${rackText(i)}</td>
|
||||
<td>${esc(i.location || '–')}</td>
|
||||
<td>${esc(i.location || '-')}</td>
|
||||
<td>${esc(i.notes || '')}</td>
|
||||
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-item" data-id="${esc(i.id)}" aria-label="Edit ${esc(i.name)}">Edit</button></td>` : ''}
|
||||
</tr>`).join('')}
|
||||
@@ -370,7 +370,7 @@ function openCable(cable, prefill) {
|
||||
const form = document.getElementById('cs-cable-form');
|
||||
form.reset();
|
||||
const types = ['', ..._cs.options.cable_types];
|
||||
form.cable_type.innerHTML = types.map(t => `<option value="${esc(t)}">${esc(t || '–')}</option>`).join('');
|
||||
form.cable_type.innerHTML = types.map(t => `<option value="${esc(t)}">${esc(t || '-')}</option>`).join('');
|
||||
const data = Object.assign({ status: 'installed', label: cable ? '' : _cs.next_label }, cable || {}, prefill || {});
|
||||
for (const key of ['id', 'label', 'a_device', 'a_port', 'b_device', 'b_port', 'cable_type', 'length_m', 'color', 'status', 'notes']) {
|
||||
if (form[key]) form[key].value = data[key] == null ? '' : data[key];
|
||||
@@ -392,11 +392,13 @@ function openItem(item) {
|
||||
const data = Object.assign({ kind: 'patch_panel', mount: 'front' }, item || {});
|
||||
document.getElementById('cs-rack-list').innerHTML = (_cs.racks || []).map(r => `<option value="${esc(r)}">`).join('');
|
||||
document.getElementById('cs-known-devices').innerHTML = (_cs.devices || []).map(d => `<option value="${esc(d)}">`).join('');
|
||||
form.chassis.innerHTML = '<option value="">Automatic (faceplate or plain plate)</option>' +
|
||||
Object.entries(_cs.chassis || {}).map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`).join('');
|
||||
const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id));
|
||||
form.shelf.innerHTML = '<option value="">Not on a shelf</option>' +
|
||||
shelves.map(i => `<option value="${esc(i.name)}">${esc(i.name)}</option>`).join('');
|
||||
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
|
||||
'location', 'notes']) {
|
||||
'chassis', 'location', 'notes']) {
|
||||
form[key].value = data[key] == null ? '' : data[key];
|
||||
}
|
||||
if (!item) form.port_count.value = 24;
|
||||
@@ -450,7 +452,7 @@ async function saveItem(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
|
||||
'location', 'notes']);
|
||||
'chassis', 'location', 'notes']);
|
||||
const id = form.id.value;
|
||||
await save(() => id ? lt.api.put(`/api/cutsheet/items/${encodeURIComponent(id)}`, body)
|
||||
: lt.api.post('/api/cutsheet/items', body), 'Saved', 'cs-item-modal');
|
||||
|
||||
+15
-15
@@ -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>` : '';
|
||||
}
|
||||
|
||||
|
||||
+48
-5
@@ -225,7 +225,21 @@ function fpQuadScreen(x, y, s) {
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const fpBrand = (x, y, text) => `<text class="fp-brand" x="${x}" y="${y}">${text}</text>`;
|
||||
// The model name printed under a UniFi screen: centred on the screen, first word bold and
|
||||
// second word light ("USW Pro", "NVR Pro"), as on the real units. `bolt` adds the PoE mark.
|
||||
function fpModel(cx, y, bold, light = '', { size = 7.5, bolt = false } = {}) {
|
||||
const chars = bold.length + (light ? light.length + 1 : 0);
|
||||
const boltW = bolt ? size * 1.3 : 0;
|
||||
const textW = chars * size * 0.6;
|
||||
const x = cx - boltW / 2;
|
||||
const mark = bolt
|
||||
? `<g transform="translate(${x + textW / 2 + size * 0.75} ${y - size * 0.4})">
|
||||
<circle class="fp-brand-bolt-ring" r="${size * 0.62}"/>
|
||||
<path class="fp-brand-bolt" transform="scale(${size / 11})" d="M1 -5 L-3 1 H0 L-1 5 L3 -1 H0 Z"/></g>`
|
||||
: '';
|
||||
return `<text class="fp-brand" x="${x}" y="${y}" text-anchor="middle" style="font-size:${size}px">${bold}${light
|
||||
? `<tspan class="fp-brand-light" dx="${size * 0.25}">${light}</tspan>` : ''}</text>${mark}`;
|
||||
}
|
||||
|
||||
// Two rows of jacks, odd numbers on top: how UniFi rack switches number ports
|
||||
function rj45Pairs(columns, rows) {
|
||||
@@ -270,7 +284,7 @@ const FACEPLATES = {
|
||||
<rect class="fp-screen" x="15" y="25" width="56" height="56" rx="3"/>
|
||||
<circle class="fp-screen-ring" cx="43" cy="51" r="13"/>
|
||||
<circle class="fp-screen-dot" cx="43" cy="51" r="4.5"/>
|
||||
${fpBrand(16, 94, 'Dream Machine Pro')}
|
||||
${fpModel(43, 94, 'UDM', 'Pro')}
|
||||
<rect class="fp-bay" x="447" y="19" width="259" height="66" rx="1.5"/>
|
||||
<rect class="fp-bay-edge" x="447.8" y="19.8" width="3.5" height="64.4"/>
|
||||
<circle class="fp-bay-led" cx="681" cy="52" r="1.6"/>
|
||||
@@ -284,6 +298,35 @@ const FACEPLATES = {
|
||||
},
|
||||
},
|
||||
|
||||
// UniFi NVR Pro, 2U. Layout from the CC0 NetBox devicetype-library front image
|
||||
// (elevation-images/Ubiquiti/ubiquiti-unifi-protect-network-video-recorder-pro.front.png):
|
||||
// touchscreen top left, bays 1-3 on top and 4-7 below, RJ45 (1) and SFP+ (2) top right.
|
||||
// UniFi Network doesn't list a Protect NVR, so on the rack it is picked by Front drawing;
|
||||
// if it is ever reported as UNVRPRO its ports go live like any other.
|
||||
UNVRPRO: {
|
||||
width: 1167, height: 230, frameClass: 'fp-scroll', scrollX: 700,
|
||||
label: 'UniFi NVR Pro',
|
||||
legend: [...LEGEND_RJ45, ['white', '10G']],
|
||||
roles: { 1: 'RJ45 1G', 2: 'SFP+ 10G' },
|
||||
ports: [
|
||||
{ idx: 1, type: 'rj45', x: 1040, y: 74, scale: 1.3, label: [1040, 44] },
|
||||
{ idx: 2, type: 'sfp', x: 1072, y: 61, w: 40, h: 27, led: [1092, 96], label: [1092, 44] },
|
||||
],
|
||||
decor() {
|
||||
const spans = [[97, 293], [302, 498], [507, 703], [712, 908], [916, 1112]];
|
||||
const bay = (x, y, n) => `<rect class="fp-bay" x="${x}" y="${y}" width="283" height="76" rx="1.5"/>
|
||||
<rect class="fp-bay-edge" x="${x + 1}" y="${y + 1}" width="4.5" height="74"/>
|
||||
<circle class="fp-bay-led" cx="${x + 255}" cy="${y + 38}" r="2.2"/>
|
||||
<text class="fp-label" x="${x + 141}" y="${y + 89}" text-anchor="middle">${n}</text>`;
|
||||
return `${fpVents(spans, 8, 4)}${fpVents(spans, 216, 4)}
|
||||
${fpQuadScreen(14, 33, 62)}${fpModel(45, 111, 'NVR', 'Pro')}
|
||||
${[97, 384, 669].map((x, i) => bay(x, 27, i + 1)).join('')}
|
||||
${[12, 299, 585, 871].map((x, i) => bay(x, 125, i + 4)).join('')}
|
||||
<rect class="fp-block" x="1014" y="52" width="104" height="44" rx="1"/>
|
||||
${fpReset(1124, 219)}`;
|
||||
},
|
||||
},
|
||||
|
||||
// Port layout and LED colors: https://dl.ui.com/qsg/USW-Pro-24/USW-Pro-24_EN.html
|
||||
// (the non-PoE sibling). PoE draw has no documented LED, so it is a bolt by the number.
|
||||
US24PRO: {
|
||||
@@ -299,7 +342,7 @@ const FACEPLATES = {
|
||||
],
|
||||
decor() {
|
||||
return `${fpVents([[98, 298], [304, 504], [512, 710], [717, 917], [923, 1122]], 3)}
|
||||
${fpQuadScreen(15, 23, 62)}${fpBrand(17, 99, 'USW Pro')}
|
||||
${fpQuadScreen(15, 23, 62)}${fpModel(46, 99, 'USW', 'Pro', { bolt: true })}
|
||||
<rect class="fp-block" x="563" y="19" width="234" height="69" rx="1"/>
|
||||
<rect class="fp-block" x="797.5" y="19" width="233" height="69" rx="1"/>
|
||||
<rect class="fp-divider" x="1083" y="45" width="39" height="16"/>
|
||||
@@ -321,7 +364,7 @@ const FACEPLATES = {
|
||||
const dividers = [876.4, 925.6, 975.6, 1025]
|
||||
.map(x => `<rect class="fp-divider" x="${x}" y="46.6" width="36" height="15"/>`).join('');
|
||||
return `${fpVents([[91, 280], [287, 476], [482, 671], [678, 867], [874, 1062]], 7)}
|
||||
${fpQuadScreen(12, 25, 58)}${fpBrand(28, 96, 'USW')}
|
||||
${fpQuadScreen(12, 25, 58)}${fpModel(41, 96, 'USW')}
|
||||
${dividers}
|
||||
<rect class="fp-speed-bar" x="874" y="99" width="188" height="4" rx="1"/>
|
||||
${fpReset(1078.6, 95)}`;
|
||||
@@ -396,7 +439,7 @@ const FACEPLATES = {
|
||||
<rect class="fp-ear-hole" x="${x + 13}" y="189" width="27" height="17" rx="8.5"/>`;
|
||||
return `${ear(0)}${ear(1126)}
|
||||
<rect class="fp-body-metal" x="48" y="1" width="1078" height="213" rx="1"/>
|
||||
${fpQuadScreen(61, 132, 56)}${fpBrand(68, 199, 'USP PDU Pro')}
|
||||
${fpQuadScreen(61, 132, 56)}${fpModel(89, 199, 'USP', 'PDU Pro')}
|
||||
<circle class="fp-button" cx="1053" cy="53" r="4.5"/>
|
||||
<rect class="fp-block" x="977" y="160" width="39" height="32.5" rx="1"/>
|
||||
<rect class="fp-block" x="1032" y="130" width="41" height="62.5" rx="1"/>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */
|
||||
// Drawn fronts for rack gear Gandalf can't read a faceplate from: custom-built servers. Chosen per cutsheet item ("Front drawing"). Each one
|
||||
// shows the device's live status on its power light when the item is linked to a device.
|
||||
//
|
||||
// Spec: { label, units, width, height, draw(ctx) -> SVG markup }, ctx: { status, name }.
|
||||
// Width:height should be about 10.5:1 per rack unit so the drawing fills its slot.
|
||||
|
||||
const FRONT_STATUS = { up: '#2fd36b', degraded: '#ffb300', down: '#e0312b' };
|
||||
|
||||
function frontLed(status) {
|
||||
return FRONT_STATUS[status] || '#6b737d';
|
||||
}
|
||||
|
||||
function ears(w, h, cls = 'rf-ear') {
|
||||
// Rack ears with their mounting slots, both sides
|
||||
const slots = Array.from({ length: Math.round(h / 105) }, (_, i) => 105 * i + 52);
|
||||
const holes = x => slots.map(y => `<rect class="rf-slot" x="${x}" y="${y - 9}" width="9" height="18" rx="4"/>`).join('');
|
||||
return `<rect class="${cls}" x="0" y="0" width="34" height="${h}" rx="3"/>${holes(12)}
|
||||
<rect class="${cls}" x="${w - 34}" y="0" width="34" height="${h}" rx="3"/>${holes(w - 21)}`;
|
||||
}
|
||||
|
||||
function vents(x0, x1, y, h = 8, gap = 6, seg = 120) {
|
||||
const out = [];
|
||||
for (let x = x0; x + seg <= x1; x += seg + gap) out.push(`<rect class="rf-vent" x="${x}" y="${y}" width="${seg}" height="${h}" rx="${h / 2}"/>`);
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function powerButton(cx, cy, r, status) {
|
||||
return `<circle class="rf-power-ring" cx="${cx}" cy="${cy}" r="${r}" style="stroke:${frontLed(status)}"/>
|
||||
<path class="rf-power-icon" d="M${cx - r * 0.35} ${cy - r * 0.3} a${r * 0.48} ${r * 0.48} 0 1 0 ${r * 0.7} 0 M${cx} ${cy - r * 0.55} v${r * 0.5}"/>`;
|
||||
}
|
||||
|
||||
function usbA(x, y) {
|
||||
return `<rect class="rf-port" x="${x}" y="${y}" width="22" height="9" rx="1"/><rect class="rf-port-tongue" x="${x + 3}" y="${y + 2}" width="16" height="3"/>`;
|
||||
}
|
||||
|
||||
function usbC(x, y) {
|
||||
return `<rect class="rf-port" x="${x}" y="${y}" width="16" height="6" rx="3"/>`;
|
||||
}
|
||||
|
||||
function keyLock(cx, cy) {
|
||||
return `<circle class="rf-lock" cx="${cx}" cy="${cy}" r="9"/><rect class="rf-lock-slot" x="${cx - 1.5}" y="${cy - 6}" width="3" height="12" rx="1"/>`;
|
||||
}
|
||||
|
||||
function brand(x, y, text, anchor = 'start') {
|
||||
return `<text class="rf-brand" x="${x}" y="${y}" text-anchor="${anchor}">${text}</text>`;
|
||||
}
|
||||
|
||||
function driveBay(x, y, w, h, vertical) {
|
||||
// A hot-swap bay: tray face, latch and activity light
|
||||
const latch = vertical
|
||||
? `<rect class="rf-latch" x="${x + w / 2 - 6}" y="${y + h - 26}" width="12" height="18" rx="2"/>`
|
||||
: `<rect class="rf-latch" x="${x + w - 26}" y="${y + h / 2 - 6}" width="18" height="12" rx="2"/>`;
|
||||
const led = vertical ? `<circle class="rf-bay-led" cx="${x + w / 2}" cy="${y + 10}" r="2.5"/>`
|
||||
: `<circle class="rf-bay-led" cx="${x + 10}" cy="${y + h / 2}" r="2.5"/>`;
|
||||
return `<rect class="rf-bay" x="${x}" y="${y}" width="${w}" height="${h}" rx="3"/>${latch}${led}`;
|
||||
}
|
||||
|
||||
const RACK_FRONTS = {
|
||||
// Sliger CX4712 4U with its faceplate off (as driveAtlas draws it): two 5.25" blanks side by
|
||||
// side, ten 3.5" hot-swap bays standing on edge in one row, a narrow I/O strip on the right.
|
||||
'sliger-cx4712': {
|
||||
label: 'Sliger CX4712 4U (10-bay)', units: 4, width: 1100, height: 420,
|
||||
draw({ status }) {
|
||||
// Bays numbered left to right, the local convention driveAtlas uses for this case
|
||||
const bays = Array.from({ length: 10 }, (_, i) => `${driveBay(78 + i * 86, 150, 78, 228, true)}
|
||||
<text class="rf-bay-num" x="${117 + i * 86}" y="396" text-anchor="middle">${i + 1}</text>`).join('');
|
||||
return `<rect class="rf-body-dark" width="1100" height="420" rx="3"/>${ears(1100, 420, 'rf-ear-dark')}
|
||||
<rect class="rf-blank" x="78" y="30" width="418" height="98" rx="3"/>
|
||||
<rect class="rf-blank" x="510" y="30" width="418" height="98" rx="3"/>
|
||||
${vents(98, 480, 74, 7, 8, 40)}${vents(530, 912, 74, 7, 8, 40)}
|
||||
${bays}
|
||||
<rect class="rf-io" x="950" y="30" width="104" height="360" rx="4"/>
|
||||
${powerButton(1002, 78, 20, status)}
|
||||
${usbA(991, 130)}${usbA(991, 150)}${usbC(995, 176)}
|
||||
${keyLock(1002, 220)}
|
||||
${brand(1002, 372, 'SLIGER', 'middle')}`;
|
||||
},
|
||||
},
|
||||
|
||||
// Rosewill RSV-L4500U 4U: the perforated, lockable drop-down door over the fan wall
|
||||
// (15 bays sit behind it), D-handles on the ears, I/O strip with power and USB on the right.
|
||||
'rosewill-rsv-l4500u': {
|
||||
label: 'Rosewill RSV-L4500U 4U (15-bay)', units: 4, width: 1100, height: 420,
|
||||
draw({ status }) {
|
||||
const fan = cx => `<circle class="rf-fan" cx="${cx}" cy="210" r="112"/>
|
||||
<circle class="rf-fan-hub" cx="${cx}" cy="210" r="30"/>
|
||||
${[0, 72, 144, 216, 288].map(a => `<path class="rf-fan-blade" transform="rotate(${a} ${cx} 210)" d="M${cx} 180 q40 -40 70 -10 q-30 10 -70 40 z"/>`).join('')}`;
|
||||
const handle = x => `<rect class="rf-handle" x="${x}" y="60" width="16" height="300" rx="8"/>`;
|
||||
return `<rect class="rf-body-dark" width="1100" height="420" rx="3"/>${ears(1100, 420, 'rf-ear-dark')}
|
||||
${handle(36)}${handle(1048)}
|
||||
<defs><pattern id="rf-perf" width="9" height="9" patternUnits="userSpaceOnUse">
|
||||
<circle class="rf-perf-hole" cx="4.5" cy="4.5" r="2.4"/></pattern></defs>
|
||||
<rect class="rf-door" x="64" y="16" width="880" height="388" rx="6"/>
|
||||
${fan(225)}${fan(504)}${fan(783)}
|
||||
<rect class="rf-mesh" x="80" y="32" width="848" height="356" rx="4" fill="url(#rf-perf)"/>
|
||||
<rect class="rf-door-latch" x="900" y="180" width="26" height="60" rx="5"/>
|
||||
<rect class="rf-io" x="954" y="16" width="86" height="388" rx="4"/>
|
||||
${powerButton(997, 62, 18, status)}
|
||||
<circle class="rf-reset" cx="997" cy="104" r="5"/>
|
||||
<circle class="rf-led" cx="985" cy="130" r="3.5" style="fill:${frontLed(status)}"/><circle class="rf-led" cx="1009" cy="130" r="3.5"/>
|
||||
${usbA(986, 160)}${usbA(986, 180)}
|
||||
${keyLock(997, 232)}
|
||||
${brand(997, 388, 'ROSEWILL', 'middle')}`;
|
||||
},
|
||||
},
|
||||
|
||||
// Generic servers, for gear without a specific drawing
|
||||
'server-1u': genericServer(1, 4),
|
||||
'server-2u': genericServer(2, 12),
|
||||
'server-4u': genericServer(4, 24),
|
||||
};
|
||||
|
||||
function genericServer(units, bays) {
|
||||
const H = 105 * units;
|
||||
return {
|
||||
label: `Generic ${units}U server`, units, width: 1100, height: H,
|
||||
draw({ status }) {
|
||||
const rows = Math.max(1, Math.round(units * 0.75));
|
||||
const cols = Math.ceil(bays / rows);
|
||||
const bw = Math.min(118, 720 / cols - 8);
|
||||
const bh = (H - 30 - (rows - 1) * 8) / rows;
|
||||
const out = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols && r * cols + c < bays; c++) out.push(driveBay(60 + c * (bw + 8), 15 + r * (bh + 8), bw, bh, false));
|
||||
}
|
||||
return `<rect class="rf-body-dark" width="1100" height="${H}" rx="3"/>${ears(1100, H, 'rf-ear-dark')}
|
||||
${out.join('')}
|
||||
${vents(810, 1040, 18, 7, 6, 36)}${units > 1 ? vents(810, 1040, H - 26, 7, 6, 36) : ''}
|
||||
${powerButton(1020, H / 2, Math.min(18, H / 5), status)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Front drawings that are full faceplates in faceplates.js (same parts as the UniFi gear)
|
||||
const CHASSIS_FACEPLATES = { 'unvr-pro': 'UNVRPRO' };
|
||||
|
||||
// The markup for one item's drawn front, or '' when the key is unknown
|
||||
function rackFrontSvg(key, ctx) {
|
||||
const spec = RACK_FRONTS[key];
|
||||
if (!spec) return '';
|
||||
return `<svg class="rack-svg rack-front" viewBox="0 0 ${spec.width} ${spec.height}" preserveAspectRatio="none"
|
||||
role="img" aria-label="${lt.escHtml(`${ctx.name}: ${spec.label}${ctx.status ? `, ${ctx.status}` : ''}`)}">
|
||||
<title>${lt.escHtml(`${ctx.name} · ${spec.label}${ctx.status ? ` · ${ctx.status}` : ''}`)}</title>
|
||||
${spec.draw(ctx)}
|
||||
</svg>`;
|
||||
}
|
||||
+27
-27
@@ -9,13 +9,13 @@ const escHtml = s => lt.escHtml(s);
|
||||
|
||||
// ── Formatting helpers ───────────────────────────────────────────────────
|
||||
function fmtShortSpeed(mbps) {
|
||||
if (!mbps) return '–';
|
||||
if (!mbps) return '-';
|
||||
if (mbps >= 1000) return (mbps / 1000).toFixed(0) + 'G';
|
||||
return mbps + 'M';
|
||||
}
|
||||
|
||||
function fmtRate(bytesPerSec) {
|
||||
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
|
||||
if (bytesPerSec === null || bytesPerSec === undefined) return '-';
|
||||
const bps = bytesPerSec * 8;
|
||||
if (bps < 1e3) return bps.toFixed(0) + ' bps';
|
||||
if (bps < 1e6) return (bps / 1e3).toFixed(1) + ' Kbps';
|
||||
@@ -24,7 +24,7 @@ function fmtRate(bytesPerSec) {
|
||||
}
|
||||
|
||||
function fmtErrors(rate) {
|
||||
if (rate === null || rate === undefined) return '–';
|
||||
if (rate === null || rate === undefined) return '-';
|
||||
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
|
||||
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
||||
}
|
||||
@@ -298,11 +298,11 @@ function renderPanel(swName, idx) {
|
||||
if (!d) return;
|
||||
|
||||
const upStr = d.up ? '<span class="val-good">UP</span>' : '<span class="val-crit">DOWN</span>';
|
||||
const speedStr = d.speed_mbps ? `<span class="val-cyan">${fmtShortSpeed(d.speed_mbps)}bps</span>` : '–';
|
||||
const speedStr = d.speed_mbps ? `<span class="val-cyan">${fmtShortSpeed(d.speed_mbps)}bps</span>` : '-';
|
||||
const duplexCls = d.full_duplex ? 'val-good' : (d.up ? 'val-warn' : 'val-neutral');
|
||||
const duplexStr = d.up ? `<span class="${duplexCls}">${d.full_duplex ? 'Full' : 'Half'}</span>` : '–';
|
||||
const duplexStr = d.up ? `<span class="${duplexCls}">${d.full_duplex ? 'Full' : 'Half'}</span>` : '-';
|
||||
const autoneg = d.autoneg ? 'On' : 'Off';
|
||||
const mediaStr = d.media || '–';
|
||||
const mediaStr = d.media || '-';
|
||||
const isUplinkBadge = d.is_uplink ? ' <span class="port-badge port-badge-uplink">UPLINK</span>' : '';
|
||||
|
||||
// PoE section
|
||||
@@ -321,8 +321,8 @@ function renderPanel(swName, idx) {
|
||||
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
|
||||
trafficHtml = `
|
||||
<div class="lt-divider"><span class="lt-divider-label">Traffic</span></div>
|
||||
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit — outgoing from this port">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
|
||||
<div class="panel-row"><span class="panel-label" data-tooltip="Receive — incoming to this port">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
|
||||
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit: outgoing from this port">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
|
||||
<div class="panel-row"><span class="panel-label" data-tooltip="Receive: incoming to this port">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
|
||||
}
|
||||
|
||||
// Errors / drops section
|
||||
@@ -447,14 +447,14 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
||||
const duplexMismatch = swPort.up && svrData.duplex &&
|
||||
((swFull && !svrFull) || (!swFull && svrFull));
|
||||
const duplexWarnHtml = duplexMismatch
|
||||
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH — Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
|
||||
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH. Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
|
||||
: '';
|
||||
|
||||
// Detect speed mismatch
|
||||
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
|
||||
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
|
||||
const speedWarnHtml = speedMismatch
|
||||
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}</div>`
|
||||
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH. Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
@@ -474,7 +474,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Server: ${escHtml(serverName)}</div>
|
||||
<div class="path-row"><span>Iface</span><span>${escHtml(ifaceName)}</span></div>
|
||||
<div class="path-row"><span>Speed</span><span>${svrData.speed_mbps ? fmtShortSpeed(svrData.speed_mbps) + 'bps' : '–'}</span></div>
|
||||
<div class="path-row"><span>Speed</span><span>${svrData.speed_mbps ? fmtShortSpeed(svrData.speed_mbps) + 'bps' : '-'}</span></div>
|
||||
<div class="path-row"><span>TX</span><span>${fmtRate(svrData.tx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div>
|
||||
@@ -587,7 +587,7 @@ function runDiagnostic(swName, portIdx) {
|
||||
})
|
||||
.catch(e => {
|
||||
const msg = (e && e.status === 429)
|
||||
? 'Rate limit reached — max 5 diagnostics per minute. Please wait.'
|
||||
? 'Rate limit reached: max 5 diagnostics per minute. Please wait.'
|
||||
: 'Error: ' + (e && e.message || 'Request failed');
|
||||
statusEl.textContent = msg;
|
||||
});
|
||||
@@ -666,21 +666,21 @@ function renderDiagnosticResults(d, container) {
|
||||
const issueRows = [...issues, ...warns, ...infoArr].map(item => {
|
||||
const cls = issues.includes(item) ? 'diag-val-bad' : warns.includes(item) ? 'diag-val-warn' : 'diag-val-good';
|
||||
const label = issues.includes(item) ? 'CRIT' : warns.includes(item) ? 'WARN' : 'INFO';
|
||||
return `<div class="diag-issue-row"><span class="${cls}">[${label}]</span> <span class="diag-code">${escHtml(item.code)}</span> — ${escHtml(item.message)}</div>`;
|
||||
return `<div class="diag-issue-row"><span class="${cls}">[${label}]</span> <span class="diag-code">${escHtml(item.code)}</span>: ${escHtml(item.message)}</div>`;
|
||||
}).join('');
|
||||
|
||||
// ── Physical layer ──
|
||||
const carrierVal = secs.carrier === '1' ? '<span class="diag-val-good">YES</span>' :
|
||||
secs.carrier === '0' ? '<span class="diag-val-bad">NO</span>' : '–';
|
||||
secs.carrier === '0' ? '<span class="diag-val-bad">NO</span>' : '-';
|
||||
const operstateVal = (secs.operstate || '?').toUpperCase();
|
||||
const opstateCls = secs.operstate === 'up' ? 'diag-val-good' : secs.operstate === 'down' ? 'diag-val-bad' : 'diag-val-warn';
|
||||
const speedVal = eth.speed_mbps ? `<span class="diag-val-good">${fmtShortSpeed(eth.speed_mbps)}bps</span>` : '<span class="diag-val-warn">–</span>';
|
||||
const speedVal = eth.speed_mbps ? `<span class="diag-val-good">${fmtShortSpeed(eth.speed_mbps)}bps</span>` : '<span class="diag-val-warn">-</span>';
|
||||
const duplexVal = eth.duplex === 'full' ? '<span class="diag-val-good">Full</span>' :
|
||||
eth.duplex === 'half' ? '<span class="diag-val-bad">Half</span>' : '–';
|
||||
eth.duplex === 'half' ? '<span class="diag-val-bad">Half</span>' : '-';
|
||||
const linkDetVal = eth.link_detected === true ? '<span class="diag-val-good">Yes</span>' :
|
||||
eth.link_detected === false ? '<span class="diag-val-bad">No</span>' : '–';
|
||||
eth.link_detected === false ? '<span class="diag-val-bad">No</span>' : '-';
|
||||
const autonegVal = eth.auto_neg === true ? '<span class="diag-val-good">On</span>' :
|
||||
eth.auto_neg === false ? '<span class="diag-val-warn">Off</span>' : '–';
|
||||
eth.auto_neg === false ? '<span class="diag-val-warn">Off</span>' : '-';
|
||||
|
||||
const physHtml = `
|
||||
<div class="diag-section">
|
||||
@@ -769,8 +769,8 @@ function renderDiagnosticResults(d, container) {
|
||||
<tr><td>RX Pause</td><td>${pause.rx_pause ? '<span class="diag-val-good">On</span>' : 'Off'}</td></tr>
|
||||
<tr><td>TX Pause</td><td>${pause.tx_pause ? '<span class="diag-val-good">On</span>' : 'Off'}</td></tr>` : ''}
|
||||
${hasRing ? `
|
||||
<tr><td>RX Ring</td><td>${ring.rx_current != null ? ring.rx_current : '–'} / ${ring.rx_max != null ? ring.rx_max : '–'} max</td></tr>
|
||||
<tr><td>TX Ring</td><td>${ring.tx_current != null ? ring.tx_current : '–'} / ${ring.tx_max != null ? ring.tx_max : '–'} max</td></tr>` : ''}
|
||||
<tr><td>RX Ring</td><td>${ring.rx_current != null ? ring.rx_current : '-'} / ${ring.rx_max != null ? ring.rx_max : '-'} max</td></tr>
|
||||
<tr><td>TX Ring</td><td>${ring.tx_current != null ? ring.tx_current : '-'} / ${ring.tx_max != null ? ring.tx_max : '-'} max</td></tr>` : ''}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
@@ -799,15 +799,15 @@ function renderDiagnosticResults(d, container) {
|
||||
<div class="path-debug-cols">
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Switch sees</div>
|
||||
<div class="path-row"><span>System</span><span>${escHtml(swLldp.system_name || '–')}</span></div>
|
||||
<div class="path-row"><span>Port</span><span>${escHtml(swLldp.port_id || '–')}</span></div>
|
||||
<div class="path-row"><span>Chassis</span><span>${escHtml(swLldp.chassis_id || '–')}</span></div>
|
||||
<div class="path-row"><span>System</span><span>${escHtml(swLldp.system_name || '-')}</span></div>
|
||||
<div class="path-row"><span>Port</span><span>${escHtml(swLldp.port_id || '-')}</span></div>
|
||||
<div class="path-row"><span>Chassis</span><span>${escHtml(swLldp.chassis_id || '-')}</span></div>
|
||||
</div>
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Server lldpctl</div>
|
||||
${lldpctl.available
|
||||
? `<div class="path-row"><span>Neighbor</span><span>${escHtml(lldpctl.neighbor_system || '–')}</span></div>
|
||||
<div class="path-row"><span>Port</span><span>${escHtml(lldpctl.neighbor_port || '–')}</span></div>`
|
||||
? `<div class="path-row"><span>Neighbor</span><span>${escHtml(lldpctl.neighbor_system || '-')}</span></div>
|
||||
<div class="path-row"><span>Port</span><span>${escHtml(lldpctl.neighbor_port || '-')}</span></div>`
|
||||
: '<div class="path-row"><span class="diag-val-warn">lldpd not running</span></div>'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -838,8 +838,8 @@ function renderDiagnosticResults(d, container) {
|
||||
<div class="diag-section-header">Switch Port Summary</div>
|
||||
<table class="diag-table">
|
||||
<tr><td>Status</td><td>${swPort.up ? '<span class="diag-val-good">UP</span>' : '<span class="diag-val-bad">DOWN</span>'}</td></tr>
|
||||
<tr><td>Speed</td><td>${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '–'}</td></tr>
|
||||
<tr><td>Duplex</td><td>${swPort.full_duplex ? 'Full' : (swPort.up ? '<span class="diag-val-bad">Half</span>' : '–')}</td></tr>
|
||||
<tr><td>Speed</td><td>${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '-'}</td></tr>
|
||||
<tr><td>Duplex</td><td>${swPort.full_duplex ? 'Full' : (swPort.up ? '<span class="diag-val-bad">Half</span>' : '-')}</td></tr>
|
||||
<tr><td>TX Err</td><td>${fmtErrors(swPort.tx_errs_rate)}</td></tr>
|
||||
<tr><td>RX Err</td><td>${fmtErrors(swPort.rx_errs_rate)}</td></tr>
|
||||
${swPort.poe_power != null ? `<tr><td>PoE</td><td><span class="val-amber">${swPort.poe_power.toFixed(1)}W</span></td></tr>` : ''}
|
||||
|
||||
+51
-5
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT */
|
||||
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */
|
||||
// Rack page: draws the rack front from /api/rack. Switches use their real faceplates,
|
||||
// patch panels a generic panel; hovering a port lights up every port on the same cable run.
|
||||
|
||||
@@ -38,9 +38,10 @@ function isRackFaceplate(model) {
|
||||
}
|
||||
|
||||
// ── Item drawings ───────────────────────────────────────────────────────
|
||||
function switchHtml(item) {
|
||||
const sw = item.switch;
|
||||
return renderFaceplate(sw.model, {
|
||||
function switchHtml(item, model) {
|
||||
// A live switch, or a faceplate picked by Front drawing (no port data: drawn unlinked)
|
||||
const sw = item.switch || { model, ports: {}, outlets: [], online: item.status === 'up' };
|
||||
return renderFaceplate(model || sw.model, {
|
||||
swName: item.device, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, rack: true,
|
||||
titleFor: (port, idx) => portTitle(port, idx),
|
||||
cutsheetFor: idx => {
|
||||
@@ -104,6 +105,48 @@ function shelfHtml(item) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Ubiquiti UACC-Rack-Panel-Patch-Blank-24, laid out from the CC0 NetBox devicetype-library image
|
||||
// (elevation-images/Ubiquiti/ubiquiti-uacc-rack-panel-patch-blank-24.front.png): a grey steel face,
|
||||
// darker ears with oval holes, 24 square keystone openings in one row with the cable-support bar
|
||||
// behind them, and a narrow cable slot at each end. A port with a documented cable gets a keystone jack.
|
||||
function uaccPanelSvg(item) {
|
||||
const W = 1178, H = 108, x0 = 107, pitch = 40.5, ow = 34, oy = 36, oh = 40;
|
||||
const count = Math.min(item.port_count || 24, 24);
|
||||
const ports = [];
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const x = x0 + (n - 1) * pitch;
|
||||
const m = item.ports[String(n)];
|
||||
const label = item.labels[String(n)] || '';
|
||||
const title = [`Port ${n}`, label && `"${label}"`, m ? m.text : 'not on the cutsheet'].filter(Boolean).join(' · ');
|
||||
const attrs = m ? ` data-run="${m.run.join(' ')}" data-cable="${m.cable_id}" tabindex="0" role="link"` : '';
|
||||
const jack = m ? `<rect class="ua-keystone" x="${x + 3}" y="${oy + 3}" width="${ow - 6}" height="${oh - 6}" rx="2"/>
|
||||
<path class="ua-keystone-hole" d="M${x + 7} ${oy + 13} h20 v14 h-4 v4 h-12 v-4 h-4 z"/>` : '';
|
||||
ports.push(`<g class="rack-pp-port ua-port${m ? ` rack-pp-${m.mark}` : ''}"${attrs}>
|
||||
<title>${esc(title)}</title>
|
||||
<rect class="ua-open" x="${x}" y="${oy}" width="${ow}" height="${oh}"/>
|
||||
<rect class="ua-bar" x="${x}" y="${oy + 19}" width="${ow}" height="5"/>
|
||||
${jack}
|
||||
<rect class="rack-pp-jack ua-mark" x="${x - 1}" y="${oy - 1}" width="${ow + 2}" height="${oh + 2}" rx="1"/>
|
||||
<text class="ua-num" x="${x + ow / 2}" y="${oy - 6}" text-anchor="middle">${n}</text>
|
||||
${label ? `<text class="ua-label" x="${x + ow / 2}" y="${oy + oh + 12}" text-anchor="middle">${esc(label.length > 7 ? `${label.slice(0, 6)}…` : label)}</text>` : ''}
|
||||
<rect class="rack-pp-hit" x="${x - 3}" y="${oy - 16}" width="${pitch}" height="${oh + 32}"/>
|
||||
</g>`);
|
||||
}
|
||||
const hole = (x, y) => `<rect class="ua-hole" x="${x}" y="${y}" width="22" height="13" rx="6.5"/>`;
|
||||
return `<svg class="rack-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="group"
|
||||
aria-label="${esc(item.name)} patch panel">
|
||||
<title>${esc(item.name)}</title>
|
||||
<rect class="ua-ear" x="0" y="2" width="44" height="${H - 4}" rx="3"/>
|
||||
<rect class="ua-ear" x="${W - 44}" y="2" width="44" height="${H - 4}" rx="3"/>
|
||||
${hole(8, 12)}${hole(8, 83)}${hole(W - 30, 12)}${hole(W - 30, 83)}
|
||||
<rect class="ua-face" x="36" y="0" width="${W - 72}" height="${H}" rx="2"/>
|
||||
<rect class="ua-lip" x="36" y="0" width="${W - 72}" height="4"/>
|
||||
<rect class="ua-open" x="62" y="${oy}" width="28" height="${oh}"/><rect class="ua-bar" x="62" y="${oy + 19}" width="28" height="5"/>
|
||||
<rect class="ua-open" x="${W - 95}" y="${oy}" width="28" height="${oh}"/><rect class="ua-bar" x="${W - 95}" y="${oy + 19}" width="28" height="5"/>
|
||||
${ports.join('')}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function plateHtml(item) {
|
||||
const unlinked = item.kind === 'device' && !item.linked;
|
||||
const sub = [item.device !== item.name ? item.device : '', KIND_TEXT[item.kind] || item.kind, item.location,
|
||||
@@ -126,6 +169,9 @@ function itemHtml(item) {
|
||||
let body;
|
||||
if (item.through) body = backHtml(item);
|
||||
else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
|
||||
else if (CHASSIS_FACEPLATES[item.chassis]) body = switchHtml(item, CHASSIS_FACEPLATES[item.chassis]);
|
||||
else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
|
||||
else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item);
|
||||
else if (item.kind === 'patch_panel') body = panelSvg(item);
|
||||
else if (item.kind === 'shelf') body = shelfHtml(item);
|
||||
else body = plateHtml(item);
|
||||
@@ -149,7 +195,7 @@ function gapRow(top, bottom) {
|
||||
const n = top - bottom + 1;
|
||||
return `<div class="rack-row rack-row-gap" data-gap="${n}" style="--n:${n}">
|
||||
<div class="rack-u" aria-hidden="true"></div>
|
||||
<div class="rack-gap">U${bottom}–${top} · ${n}U free</div>
|
||||
<div class="rack-gap">U${bottom}-${top} · ${n}U free</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
+67
-7
@@ -1,5 +1,5 @@
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
GANDALF – App-specific styles (extends LotusGuild base.css)
|
||||
GANDALF: App-specific styles (extends LotusGuild base.css)
|
||||
base.css handles: body, header, nav, tables, buttons, forms, modals,
|
||||
badges, toasts, layout shell, and the full design token system.
|
||||
This file adds only gandalf-specific components.
|
||||
@@ -96,7 +96,7 @@
|
||||
content: '…';
|
||||
}
|
||||
|
||||
/* ── Secondary button – dark-mode definition ─────────────────────────
|
||||
/* ── Secondary button: dark-mode definition ─────────────────────────
|
||||
base.css only defines .lt-btn-secondary in its light-theme block,
|
||||
so dark mode falls back to the default cyan primary appearance.
|
||||
This restores a visually distinct secondary look in dark mode. */
|
||||
@@ -317,7 +317,7 @@
|
||||
overflow: hidden;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
/* Corner accent triangle — mirrors test code's status-tinted corner */
|
||||
/* Corner accent triangle: mirrors test code's status-tinted corner */
|
||||
.host-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -485,7 +485,7 @@
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Top highlight strip — color matches node type / status */
|
||||
/* Top highlight strip: color matches node type / status */
|
||||
.topo-v2-node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -712,7 +712,7 @@
|
||||
.counter-zero { color: var(--green); }
|
||||
.counter-nonzero { color: var(--red); text-shadow: var(--glow-red); }
|
||||
|
||||
/* Traffic bars — use lt-progress from base.css */
|
||||
/* Traffic bars: use lt-progress from base.css */
|
||||
.traffic-section { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,107,0,.08); }
|
||||
.traffic-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
|
||||
.traffic-label { font-size: .62em; color: var(--text-muted); width: 20px; text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
|
||||
@@ -945,7 +945,7 @@
|
||||
transition: all .15s;
|
||||
}
|
||||
.panel-close:hover { color: var(--red); border-color: var(--red); }
|
||||
/* Inspector panel uses lt-divider — compact spacing overrides */
|
||||
/* Inspector panel uses lt-divider: compact spacing overrides */
|
||||
.inspector-panel .lt-divider { margin: 8px 0 4px; }
|
||||
.inspector-panel .lt-divider-label { color: var(--amber); font-size: .6em; }
|
||||
.panel-row {
|
||||
@@ -1190,7 +1190,10 @@ tr.device-row.is-selected { background: var(--bg-tertiary); box-shadow: inset 3p
|
||||
.fp-screen { fill: #0c1020; stroke: #2a2c34; stroke-width: 1; }
|
||||
.fp-screen-ring{ fill: none; stroke: #3f7bd9; stroke-width: 2.2; }
|
||||
.fp-screen-dot { fill: #dfe8f8; }
|
||||
.fp-brand { fill: #55565c; font: 600 7.5px Arial, Helvetica, sans-serif; }
|
||||
.fp-brand { fill: #2e2f33; font: 700 7.5px Arial, Helvetica, sans-serif; }
|
||||
.fp-brand-light{ font-weight: 400; fill: #55565c; }
|
||||
.fp-brand-bolt-ring { fill: #2e2f33; }
|
||||
.fp-brand-bolt { fill: #e4e4e6; }
|
||||
.fp-bay { fill: url(#fp-bay-face); stroke: #54555a; stroke-width: 1; }
|
||||
.fp-bay-edge { fill: #bdbdc1; }
|
||||
.fp-bay-led { fill: #3a3a3e; }
|
||||
@@ -1524,3 +1527,60 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.rack-face-btn + .rack-face-btn { border-left: 1px solid var(--border-color); }
|
||||
.rack-face-btn.active { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||
.rack-back { opacity: .75; background: repeating-linear-gradient(135deg, var(--rk-plate-b) 0 8px, var(--rk-frame) 8px 16px); }
|
||||
|
||||
/* ── Drawn rack fronts (static/fronts.js) ─────────────────────────── */
|
||||
.rf-ear, .rf-ear-dark { fill: #2b3037; }
|
||||
.rf-ear { fill: #c9cbcf; }
|
||||
.rf-slot { fill: #0d1014; }
|
||||
.rf-vent { fill: #14181d; }
|
||||
.rf-body-dark { fill: #1e2329; stroke: #3a424c; }
|
||||
.rf-blank { fill: #262c33; stroke: #3d4651; }
|
||||
.rf-bay { fill: #2c333b; stroke: #4c5662; stroke-width: 1.5; }
|
||||
.rf-latch { fill: #8a96a3; }
|
||||
.rf-bay-led { fill: #3b8bff; opacity: .8; }
|
||||
.rf-io { fill: #171b20; stroke: #333b45; }
|
||||
.rf-power-ring { fill: none; stroke-width: 3; }
|
||||
.rf-power-icon { fill: none; stroke: #c7ced6; stroke-width: 2.4; stroke-linecap: round; }
|
||||
.rf-port { fill: #0b0d10; stroke: #6b737d; }
|
||||
.rf-port-tongue { fill: #2f6fd8; }
|
||||
.rf-port-led { fill: #2fd36b; }
|
||||
.rf-lock { fill: #a7b0ba; }
|
||||
.rf-lock-slot { fill: #2b3037; }
|
||||
.rf-led { fill: #6b737d; }
|
||||
.rf-reset { fill: #0b0d10; stroke: #6b737d; }
|
||||
.rf-brand { fill: #c3ccd5; font-size: 12px; font-weight: 700; letter-spacing: .14em; font-family: Helvetica, Arial, sans-serif; }
|
||||
.rf-small { fill: #4a4f57; font-size: 10px; font-family: Helvetica, Arial, sans-serif; }
|
||||
.rf-dark { fill: #1b1e22; font-weight: 700; }
|
||||
.rf-door { fill: #121519; stroke: #3a424c; stroke-width: 2; }
|
||||
.rf-mesh { stroke: #262c33; stroke-width: 2; opacity: .55; }
|
||||
.rf-perf-hole { fill: #000; }
|
||||
.rf-door-latch { fill: #3a424c; stroke: #59636f; }
|
||||
.rf-bay-num { fill: #8a96a3; font-size: 11px; font-family: Helvetica, Arial, sans-serif; }
|
||||
.rf-fan { fill: #15191e; stroke: #2e353e; stroke-width: 6; }
|
||||
.rf-fan-hub { fill: #262c33; }
|
||||
.rf-fan-blade { fill: #1f252c; stroke: #2e353e; }
|
||||
.rf-handle { fill: none; stroke: #8a96a3; stroke-width: 5; }
|
||||
.rf-unvr-body { fill: #d9dadc; stroke: #a9abaf; }
|
||||
.rf-unvr-strip { fill: #26282c; }
|
||||
.rf-unvr-bay { fill: #e9eaec; stroke: #9fa2a7; stroke-width: 1.5; }
|
||||
.rf-unvr-rail { fill: #55595f; }
|
||||
.rf-unvr-dot { fill: #ffffff; stroke: #c4c6ca; }
|
||||
.rf-screen { fill: #0f1726; }
|
||||
.rf-screen-ring { fill: none; stroke-width: 3; }
|
||||
|
||||
/* Ubiquiti UACC-Rack-Panel-Patch-Blank-24 (static/rack.js uaccPanelSvg) */
|
||||
.ua-ear { fill: #6f7277; }
|
||||
.ua-hole { fill: #15181c; }
|
||||
.ua-face { fill: #8e9095; stroke: #6c6f74; }
|
||||
.ua-lip { fill: #a6a8ad; }
|
||||
.ua-open { fill: #17191d; }
|
||||
.ua-bar { fill: #9a9ca1; }
|
||||
.ua-keystone { fill: #101113; stroke: #2b2d31; }
|
||||
.ua-keystone-hole { fill: #2f3236; }
|
||||
.ua-num { fill: #2b2d31; font-size: 9px; font-family: Arial, Helvetica, sans-serif; }
|
||||
.ua-label { fill: #1f2124; font-size: 8.5px; font-weight: 700; font-family: Arial, Helvetica, sans-serif; }
|
||||
.ua-mark { fill: none; stroke: transparent; }
|
||||
.rack-pp-ok .ua-mark { stroke: #00b860; stroke-width: 2.5; }
|
||||
.rack-pp-bad .ua-mark { stroke: #e0312b; stroke-width: 3; }
|
||||
.rack-pp-doc .ua-mark { stroke: #4c5662; stroke-width: 2; }
|
||||
.rack-pp-port.rack-hl .ua-mark { fill: rgba(0,212,255,.25); stroke: #00b8e6; stroke-width: 3.5; }
|
||||
|
||||
+5
-5
@@ -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');
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
<input class="lt-input" id="cs-item-h" name="rack_height" type="number" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="cs-item-chassis">Front drawing</label>
|
||||
<select class="lt-select" id="cs-item-chassis" name="chassis"><option value="">Automatic (faceplate or plain plate)</option></select>
|
||||
<div class="lt-field-hint">For gear Gandalf has no live faceplate for: the NVR, or a server's case.</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="cs-item-rack">Rack</label>
|
||||
|
||||
+13
-13
@@ -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;
|
||||
|
||||
@@ -84,5 +84,6 @@
|
||||
{% block scripts %}
|
||||
<script>const RACK_CAN_EDIT = {{ 'true' if can_edit else 'false' }};</script>
|
||||
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='fronts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='rack.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
{%- set _sup_badge = {'host':'badge-warning','interface':'badge-info','unifi_device':'badge-purple','all':'badge-critical'} -%}
|
||||
<td><span class="lt-badge {{ _sup_badge.get(s.target_type, 'badge-neutral') }}">{{ s.target_type }}</span></td>
|
||||
<td>{{ s.target_name or 'all' }}</td>
|
||||
<td>{{ s.target_detail or '–' }}</td>
|
||||
<td>{{ s.target_detail or '-' }}</td>
|
||||
<td>{{ s.reason }}</td>
|
||||
<td>{{ s.suppressed_by }}</td>
|
||||
<td class="ts-cell">{{ s.created_at }}</td>
|
||||
@@ -154,7 +154,7 @@
|
||||
<tr class="{% if not s.active %}row-resolved{% endif %}">
|
||||
<td>{{ s.target_type }}</td>
|
||||
<td>{{ s.target_name or 'all' }}</td>
|
||||
<td>{{ s.target_detail or '–' }}</td>
|
||||
<td>{{ s.target_detail or '-' }}</td>
|
||||
<td>{{ s.reason }}</td>
|
||||
<td>{{ s.suppressed_by }}</td>
|
||||
<td class="ts-cell">{{ s.created_at }}</td>
|
||||
@@ -243,7 +243,7 @@
|
||||
<tr id="sup-row-${s.id}">
|
||||
<td><span class="lt-badge ${SUP_BADGE[s.target_type] || 'badge-neutral'}">${lt.escHtml(s.target_type)}</span></td>
|
||||
<td>${lt.escHtml(s.target_name || 'all')}</td>
|
||||
<td>${lt.escHtml(s.target_detail || '–')}</td>
|
||||
<td>${lt.escHtml(s.target_detail || '-')}</td>
|
||||
<td>${lt.escHtml(s.reason)}</td>
|
||||
<td>${lt.escHtml(s.suppressed_by)}</td>
|
||||
<td class="ts-cell">${lt.escHtml(s.created_at || '')}</td>
|
||||
|
||||
+6
-2
@@ -381,8 +381,8 @@ class TestHealth:
|
||||
|
||||
|
||||
class TestPageTitles:
|
||||
@pytest.mark.parametrize('path,title', [('/', 'Dashboard — GANDALF'), ('/events', 'Alert History — GANDALF'),
|
||||
('/cutsheet', 'Cutsheet — GANDALF')])
|
||||
@pytest.mark.parametrize('path,title', [('/', 'Dashboard | GANDALF'), ('/events', 'Alert History | GANDALF'),
|
||||
('/cutsheet', 'Cutsheet | GANDALF')])
|
||||
def test_name_appears_once(self, client, path, title):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
html = client.get(path, headers=AUTH).get_data(as_text=True)
|
||||
@@ -490,6 +490,10 @@ class TestRackRacksShelvesLinks:
|
||||
item = client.get('/api/rack', headers=AUTH).get_json()['items'][0]
|
||||
assert not item['linked'] and item['url'] == '/cutsheet#item-1' and 'switch' not in item
|
||||
|
||||
def test_drawn_front_is_passed_through(self, client):
|
||||
self._items({'name': 'UNVR-Pro', 'kind': 'device', 'rack_u': 5, 'rack_height': 2, 'chassis': 'unvr-pro'})
|
||||
assert client.get('/api/rack', headers=AUTH).get_json()['items'][0]['chassis'] == 'unvr-pro'
|
||||
|
||||
def test_shelf_lists_what_is_on_it(self, client):
|
||||
self._items({'name': 'Shelf', 'kind': 'shelf', 'rack_u': 3, 'rack_height': 1},
|
||||
{'name': 'large1', 'kind': 'device', 'shelf': 'Shelf'},
|
||||
|
||||
+13
-1
@@ -270,7 +270,7 @@ class TestRackLayout:
|
||||
|
||||
def test_past_the_top(self):
|
||||
out = cs.rack_layout([self._item('tall', 41, 3)], 42)
|
||||
assert out['placed'] == [] and out['outside'] == [{'name': 'tall', 'units': 'U41–43'}]
|
||||
assert out['placed'] == [] and out['outside'] == [{'name': 'tall', 'units': 'U41-43'}]
|
||||
|
||||
|
||||
class TestPortMarks:
|
||||
@@ -357,3 +357,15 @@ class TestRackFaces:
|
||||
for bad in ({'name': '', 'units': 25}, {'name': 'x'}, {'name': 'x', 'units': 61}, {'name': 'x', 'units': 0}):
|
||||
with pytest.raises(cs.ValidationError):
|
||||
cs.validate_rack(bad)
|
||||
|
||||
|
||||
class TestChassis:
|
||||
def test_known_drawing_kept_unknown_refused(self):
|
||||
assert cs.validate_item({'name': 'nvr', 'chassis': 'unvr-pro'})['chassis'] == 'unvr-pro'
|
||||
assert cs.validate_item({'name': 'x'})['chassis'] == ''
|
||||
with pytest.raises(cs.ValidationError):
|
||||
cs.validate_item({'name': 'x', 'chassis': 'tardis'})
|
||||
|
||||
def test_every_drawing_has_a_label(self):
|
||||
assert all(cs.CHASSIS.values())
|
||||
assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24'} <= set(cs.CHASSIS)
|
||||
|
||||
@@ -1,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