From 0f4e129c20b72c560306378b4369d40c4561e48e Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Nov 2026 23:25:02 -0500 Subject: [PATCH 1/5] Rack: drawn fronts for the NVR and server cases Items Gandalf has no live faceplate for can pick a Front drawing: the UniFi NVR Pro (drawn from the CC0 NetBox devicetype-library image), the Sliger CX4712 4U and Rosewill RSV-L4500U 4U cases (from driveAtlas's layouts), and generic 1U/2U/4U servers. The power light shows the linked device's live status. They export with the rest of the rack. New cutsheet_items column chassis, added on first use. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- app.py | 2 + cutsheet.py | 12 +++ db.py | 4 +- static/cutsheet.js | 6 +- static/fronts.js | 168 ++++++++++++++++++++++++++++++++++++++++ static/rack.js | 3 +- static/style.css | 37 +++++++++ templates/cutsheet.html | 5 ++ templates/rack.html | 1 + tests/test_app.py | 4 + tests/test_cutsheet.py | 11 +++ 12 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 static/fronts.js diff --git a/README.md b/README.md index ab75903..41c91bb 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,10 @@ 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 (from the CC0 NetBox device library image), Sliger CX4712 4U (10 bays), Rosewill + RSV-L4500U 4U (15 bays behind the fan door), or a generic 1U/2U/4U server. The power light shows the + linked device's live status. 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 @@ -674,6 +678,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 +722,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 diff --git a/app.py b/app.py index d03aa3b..5ea1d2e 100644 --- a/app.py +++ b/app.py @@ -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 '' diff --git a/cutsheet.py b/cutsheet.py index b3614a2..fd04604 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -26,6 +26,15 @@ 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)', + '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 +108,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']: diff --git a/db.py b/db.py index 666697c..90eed8c 100644 --- a/db.py +++ b/db.py @@ -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') diff --git a/static/cutsheet.js b/static/cutsheet.js index dac4bac..330789f 100644 --- a/static/cutsheet.js +++ b/static/cutsheet.js @@ -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 => `' + + Object.entries(_cs.chassis || {}).map(([k, v]) => ``).join(''); const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id)); form.shelf.innerHTML = '' + shelves.map(i => ``).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'); diff --git a/static/fronts.js b/static/fronts.js new file mode 100644 index 0000000..6fda2db --- /dev/null +++ b/static/fronts.js @@ -0,0 +1,168 @@ +'use strict'; +/* exported RACK_FRONTS, rackFrontSvg */ +// Drawn fronts for rack gear Gandalf can't read a faceplate from: an NVR UniFi doesn't +// report, and 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 => ``).join(''); + return `${holes(12)} + ${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(``); + return out.join(''); +} + +function powerButton(cx, cy, r, status) { + return ` + `; +} + +function usbA(x, y) { + return ``; +} + +function usbC(x, y) { + return ``; +} + +function keyLock(cx, cy) { + return ``; +} + +function brand(x, y, text, anchor = 'start') { + return `${text}`; +} + +function driveBay(x, y, w, h, vertical) { + // A hot-swap bay: tray face, latch and activity light + const latch = vertical + ? `` + : ``; + const led = vertical ? `` + : ``; + return `${latch}${led}`; +} + +const RACK_FRONTS = { + // Ubiquiti UNVR Pro: drawn from the CC0 NetBox devicetype-library front image. + // 2U; touchscreen top left, bays 1-3 on top and 4-7 below, RJ45 + SFP+ top right. + 'unvr-pro': { + label: 'UniFi NVR Pro (UNVR Pro)', units: 2, width: 1167, height: 230, + draw({ status }) { + const bay = (x, y, w, n) => ` + + + ${n}`; + const top = [97, 384, 669].map((x, i) => bay(x, 27, 283, i + 1)).join(''); + const bottom = [12, 299, 585, 871].map((x, i) => bay(x, 125, 283, i + 4)).join(''); + const strip = (y) => [97, 302, 507, 712, 916].map(x => ``).join(''); + return ` + ${strip(9)}${strip(216)} + + + NVR Pro + ${top}${bottom} + + + + 1 + 2 + RESET`; + }, + }, + + // 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 }) { + const bays = Array.from({ length: 10 }, (_, i) => driveBay(78 + i * 86, 150, 78, 240, true)).join(''); + return `${ears(1100, 420, 'rf-ear-dark')} + + + ${vents(98, 480, 74, 7, 8, 40)}${vents(530, 912, 74, 7, 8, 40)} + ${bays} + + ${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 => ` + + ${[0, 72, 144, 216, 288].map(a => ``).join('')}`; + const handle = x => ``; + return `${ears(1100, 420, 'rf-ear-dark')} + ${handle(36)}${handle(1048)} + + + ${fan(225)}${fan(504)}${fan(783)} + + ${powerButton(997, 62, 18, status)} + + + ${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 `${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)}`; + }, + }; +} + +// 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 ` + ${lt.escHtml(`${ctx.name} · ${spec.label}${ctx.status ? ` · ${ctx.status}` : ''}`)} + ${spec.draw(ctx)} + `; +} diff --git a/static/rack.js b/static/rack.js index 0a966a2..c5d4a80 100644 --- a/static/rack.js +++ b/static/rack.js @@ -1,5 +1,5 @@ 'use strict'; -/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT */ +/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, 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. @@ -126,6 +126,7 @@ function itemHtml(item) { let body; if (item.through) body = backHtml(item); else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item); + else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item); else if (item.kind === 'patch_panel') body = panelSvg(item); else if (item.kind === 'shelf') body = shelfHtml(item); else body = plateHtml(item); diff --git a/static/style.css b/static/style.css index 2d77b6f..8bc443f 100644 --- a/static/style.css +++ b/static/style.css @@ -1524,3 +1524,40 @@ 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: #9aa7b4; font-size: 13px; font-weight: 700; letter-spacing: .12em; 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 { fill: #0b0d10; stroke: #262c33; } +.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; } diff --git a/templates/cutsheet.html b/templates/cutsheet.html index fa3dc06..60884f9 100644 --- a/templates/cutsheet.html +++ b/templates/cutsheet.html @@ -263,6 +263,11 @@ +
+ + +
For gear Gandalf has no live faceplate for: the NVR, or a server's case.
+
diff --git a/templates/rack.html b/templates/rack.html index fdfb399..f1b465c 100644 --- a/templates/rack.html +++ b/templates/rack.html @@ -84,5 +84,6 @@ {% block scripts %} + {% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index 53b0b63..bfa1b17 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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'}, diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index 5441ad2..8ed6e4a 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -357,3 +357,14 @@ 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()) and 'sliger-cx4712' in cs.CHASSIS and 'rosewill-rsv-l4500u' in cs.CHASSIS From 565fdd81deb8281e48068f373470e8d35741724d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Nov 2026 23:31:19 -0500 Subject: [PATCH 2/5] Rack: NVR Pro as a real faceplate; Sliger and Rosewill touch-ups The hand-drawn NVR Pro didn't match the UniFi faceplates: flat colours, low contrast and its own port shapes. It is now a FACEPLATES entry (UNVRPRO) built from the same parts as the UDM Pro: metal body, RJ45 jack, SFP+ cage, quad screen, vents and labels. Front drawing "UniFi NVR Pro" uses it, and it would go live if UniFi ever reports the model. Sliger bays are numbered 1-10 (driveAtlas's order); the Rosewill door is perforated with a latch. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++--- static/faceplates.js | 29 +++++++++++++++++++++++++++++ static/fronts.js | 44 ++++++++++++-------------------------------- static/rack.js | 10 ++++++---- static/style.css | 7 +++++-- 5 files changed, 56 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 41c91bb..58a605c 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,10 @@ The rack front, drawn from the rack units set on cutsheet items, top first. 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 (from the CC0 NetBox device library image), Sliger CX4712 4U (10 bays), Rosewill - RSV-L4500U 4U (15 bays behind the fan door), or a generic 1U/2U/4U server. The power light shows the - linked device's live status. Drawings live in `static/fronts.js`. + 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), 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 diff --git a/static/faceplates.js b/static/faceplates.js index 4974235..8529031 100644 --- a/static/faceplates.js +++ b/static/faceplates.js @@ -284,6 +284,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) => ` + + + ${n}`; + return `${fpVents(spans, 8, 4)}${fpVents(spans, 216, 4)} + ${fpQuadScreen(14, 33, 62)}${fpBrand(26, 112, '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('')} + + ${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: { diff --git a/static/fronts.js b/static/fronts.js index 6fda2db..e8af622 100644 --- a/static/fronts.js +++ b/static/fronts.js @@ -1,7 +1,6 @@ 'use strict'; -/* exported RACK_FRONTS, rackFrontSvg */ -// Drawn fronts for rack gear Gandalf can't read a faceplate from: an NVR UniFi doesn't -// report, and custom-built servers. Chosen per cutsheet item ("Front drawing"). Each one +/* 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 }. @@ -59,39 +58,14 @@ function driveBay(x, y, w, h, vertical) { } const RACK_FRONTS = { - // Ubiquiti UNVR Pro: drawn from the CC0 NetBox devicetype-library front image. - // 2U; touchscreen top left, bays 1-3 on top and 4-7 below, RJ45 + SFP+ top right. - 'unvr-pro': { - label: 'UniFi NVR Pro (UNVR Pro)', units: 2, width: 1167, height: 230, - draw({ status }) { - const bay = (x, y, w, n) => ` - - - ${n}`; - const top = [97, 384, 669].map((x, i) => bay(x, 27, 283, i + 1)).join(''); - const bottom = [12, 299, 585, 871].map((x, i) => bay(x, 125, 283, i + 4)).join(''); - const strip = (y) => [97, 302, 507, 712, 916].map(x => ``).join(''); - return ` - ${strip(9)}${strip(216)} - - - NVR Pro - ${top}${bottom} - - - - 1 - 2 - RESET`; - }, - }, - // 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 }) { - const bays = Array.from({ length: 10 }, (_, i) => driveBay(78 + i * 86, 150, 78, 240, true)).join(''); + // 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)} + ${i + 1}`).join(''); return `${ears(1100, 420, 'rf-ear-dark')} @@ -116,9 +90,12 @@ const RACK_FRONTS = { const handle = x => ``; return `${ears(1100, 420, 'rf-ear-dark')} ${handle(36)}${handle(1048)} + + - ${fan(225)}${fan(504)}${fan(783)} + + ${powerButton(997, 62, 18, status)} @@ -156,6 +133,9 @@ function genericServer(units, bays) { }; } +// 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]; diff --git a/static/rack.js b/static/rack.js index c5d4a80..aa17ba4 100644 --- a/static/rack.js +++ b/static/rack.js @@ -1,5 +1,5 @@ 'use strict'; -/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, rackFrontSvg */ +/* 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 => { @@ -126,6 +127,7 @@ 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') body = panelSvg(item); else if (item.kind === 'shelf') body = shelfHtml(item); diff --git a/static/style.css b/static/style.css index 8bc443f..fed6043 100644 --- a/static/style.css +++ b/static/style.css @@ -1545,11 +1545,14 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .rf-lock-slot { fill: #2b3037; } .rf-led { fill: #6b737d; } .rf-reset { fill: #0b0d10; stroke: #6b737d; } -.rf-brand { fill: #9aa7b4; font-size: 13px; font-weight: 700; letter-spacing: .12em; font-family: Helvetica, Arial, sans-serif; } +.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 { fill: #0b0d10; stroke: #262c33; } +.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; } From 3c9e5fd593290cb4e6380c8eae29a06a7c574815 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Nov 2026 23:36:23 -0500 Subject: [PATCH 3/5] Faceplates: model labels as printed on the units The label under each UniFi screen was one weight, left-aligned, and on the UDM read "Dream Machine Pro". Checked against the NetBox reference photos (and the UDM against the real unit), the labels are now centred under the screen with the first word bold and the rest light: "UDM Pro", "USW Pro" with its PoE mark, "USW", "NVR Pro", "USP PDU Pro". One helper (fpModel) draws them all, so the NVR no longer looks bolder than the switches. Co-Authored-By: Claude Opus 5.5 --- static/faceplates.js | 26 ++++++++++++++++++++------ static/style.css | 5 ++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/static/faceplates.js b/static/faceplates.js index 8529031..f2cab7c 100644 --- a/static/faceplates.js +++ b/static/faceplates.js @@ -225,7 +225,21 @@ function fpQuadScreen(x, y, s) { `; } -const fpBrand = (x, 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 + ? ` + + ` + : ''; + return `${bold}${light + ? `${light}` : ''}${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 = { - ${fpBrand(16, 94, 'Dream Machine Pro')} + ${fpModel(43, 94, 'UDM', 'Pro')} @@ -305,7 +319,7 @@ const FACEPLATES = { ${n}`; return `${fpVents(spans, 8, 4)}${fpVents(spans, 216, 4)} - ${fpQuadScreen(14, 33, 62)}${fpBrand(26, 112, 'NVR Pro')} + ${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('')} @@ -328,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 })} @@ -350,7 +364,7 @@ const FACEPLATES = { const dividers = [876.4, 925.6, 975.6, 1025] .map(x => ``).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} ${fpReset(1078.6, 95)}`; @@ -425,7 +439,7 @@ const FACEPLATES = { `; return `${ear(0)}${ear(1126)} - ${fpQuadScreen(61, 132, 56)}${fpBrand(68, 199, 'USP PDU Pro')} + ${fpQuadScreen(61, 132, 56)}${fpModel(89, 199, 'USP', 'PDU Pro')} diff --git a/static/style.css b/static/style.css index fed6043..32eb5ac 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } From 619a2c913db8d809574027931fc88152296feab2 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Nov 2026 23:37:56 -0500 Subject: [PATCH 4/5] Remove em and en dashes from the UI, code and docs Page titles read "Rack | GANDALF", ranges use a hyphen (U26-42, 1-100 of 300), empty cells show "-", and dash-separated phrases use a colon, comma or full stop. In the shared base.js only the on-screen strings changed (boot line, placeholders, offline banner and toast). Co-Authored-By: Claude Opus 5.5 --- .flake8 | 4 +-- .gitea/workflows/lint.yml | 2 +- README.md | 34 +++++++++++------------ app.py | 18 ++++++------- cutsheet.py | 2 +- diagnose.py | 24 ++++++++--------- monitor.py | 20 +++++++------- schema.sql | 2 +- static/app.js | 26 +++++++++--------- static/base.js | 16 +++++------ static/cutsheet.js | 18 ++++++------- static/device.js | 30 ++++++++++----------- static/events.js | 6 ++--- static/port-inspector.js | 54 ++++++++++++++++++------------------- static/rack.js | 2 +- static/style.css | 12 ++++----- templates/base.html | 10 +++---- templates/index.html | 26 +++++++++--------- templates/suppressions.html | 6 ++--- tests/test_app.py | 4 +-- tests/test_cutsheet.py | 2 +- tests/test_diagnose.py | 2 +- 22 files changed, 160 insertions(+), 160 deletions(-) diff --git a/.flake8 b/.flake8 index 5a5abd8..50b9f61 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1,7 @@ [flake8] max-line-length = 120 -# E221: multiple spaces before operator — intentional column alignment -# E305: two blank lines after function — relaxed for module-level code +# E221: multiple spaces before operator: intentional column alignment +# E305: two blank lines after function: relaxed for module-level code extend-ignore = E221, E305 exclude = __pycache__, .git, node_modules extend-exclude = node_modules diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 20af6e9..4633bcf 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -50,7 +50,7 @@ jobs: if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi curl -sf -X POST "$MATRIX_WEBHOOK_URL" \ -H "Content-Type: application/json" \ - -d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}" + -d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH}: ${RUN_URL}\"}" deploy: name: Deploy diff --git a/README.md b/README.md index 58a605c..43906fa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Network monitoring dashboard for the LotusGuild Proxmox cluster. Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`. -**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps +**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template): shared CSS, JS, and layout patterns for all LotusGuild apps ![GANDALF walkthrough: dashboard, link debug with optical readings, switch inspector, and a one-click link diagnostic](docs/img/demo.gif) @@ -51,11 +51,11 @@ A Proxmox/Ceph cluster lives or dies by its network, and network problems are sn GANDALF uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see: -- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API -- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes) -- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, auto-refresh, fetch helpers) -- [`web_template/python/base.html`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/base.html) — Jinja2 base template -- [`web_template/python/auth.py`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/auth.py) — `@require_auth` decorator pattern +- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md): full component reference, CSS variables, JS API +- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css): unified CSS (`.lt-*` classes) +- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js): `window.lt` utilities (toast, modal, auto-refresh, fetch helpers) +- [`web_template/python/base.html`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/base.html): Jinja2 base template +- [`web_template/python/auth.py`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/auth.py): `@require_auth` decorator pattern --- @@ -82,7 +82,7 @@ Two processes share a MariaDB database: |---|---| | **Prometheus** (`10.10.10.48:9090`) | Physical NIC link state + traffic/error rates via `node_exporter` | | **UniFi API** (`https://10.10.10.1`) | Switch port stats, device status, LLDP neighbor table, PoE data | -| **Pulse Worker** | SSH relay — runs `ethtool` + SFP DOM queries on each Proxmox host | +| **Pulse Worker** | SSH relay: runs `ethtool` + SFP DOM queries on each Proxmox host | | **Ping** | Reachability for hosts without `node_exporter` (e.g. PBS) | | **AT&T BGW320** (`http://192.168.1.254`) | Fiber light levels, PON and WAN state, WAN counters, scraped read-only from the gateway's status pages (no login) | @@ -374,7 +374,7 @@ generate tickets. Baseline is stored in MariaDB and survives daemon restarts. | `all` | Everything (global maintenance mode) | Suppressions can be manual (persist until removed) or timed (auto-expire). -Expired suppressions are checked at evaluation time — no background cleanup needed. +Expired suppressions are checked at evaluation time: no background cleanup needed. --- @@ -470,7 +470,7 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/ | `monitor.poll_interval` | Seconds between full check cycles (default: 120) | | `monitor.failure_threshold` | Consecutive failures before creating ticket (default: 2) | | `monitor.host_failure_threshold` | Consecutive failures before ticketing a host that has disappeared, whether it has no metrics or doesn't answer ping (default: 5). A normal reboot takes about 3-5 minutes, so the default rides it out: the host still shows as down on the dashboard, but a ticket opens only after about 8 minutes at the default poll interval. For longer maintenance, suppress the host first. | -| `monitor.unifi_failure_threshold` | Consecutive failures before ticketing a UniFi device as offline (default: same as `failure_threshold`). Set higher than `failure_threshold` — UniFi switches/APs can take several minutes to rejoin after a firmware-update reboot, so a low threshold cuts tickets for momentary maintenance reboots instead of real failures. | +| `monitor.unifi_failure_threshold` | Consecutive failures before ticketing a UniFi device as offline (default: same as `failure_threshold`). Set higher than `failure_threshold`: UniFi switches/APs can take several minutes to rejoin after a firmware-update reboot, so a low threshold cuts tickets for momentary maintenance reboots instead of real failures. | | `monitor.cluster_threshold` | Hosts with failures to trigger cluster-wide P1 (default: 3) | | `monitor.ping_hosts` | Hosts checked only by ping (no node_exporter) | | `cutsheet.mismatch_ticket_hours` | Hours a cutsheet mismatch must last before it opens a P4 ticket (default: 24; `0` never tickets). The mismatch shows on the dashboard and the switch's page straight away, so recabling never pages anyone. Suppressing the switch or its port also stops the ticket. | @@ -485,7 +485,7 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/ ## Deployment (LXC 157) -### 1. Database — MariaDB LXC 149 (`10.10.10.50`) +### 1. Database: MariaDB LXC 149 (`10.10.10.50`) ```sql CREATE DATABASE gandalf CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -499,7 +499,7 @@ Import schema: mysql -h 10.10.10.50 -u gandalf -p gandalf < schema.sql ``` -### 2. LXC 157 — Install dependencies +### 2. LXC 157: Install dependencies ```bash pip3 install -r requirements.txt @@ -746,7 +746,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o - **Interface name validation**: `monitor.py` validates SSH interface names against `^[a-zA-Z0-9_.@-]+$` before use, and additionally wraps them with `shlex.quote()` for defense-in-depth. -- **DB parameters**: all SQL uses parameterised queries via pymysql — no string +- **DB parameters**: all SQL uses parameterised queries via pymysql: no string concatenation into SQL. - **Auth**: Authelia enforces admin-only access at the NPM/LXC 167 layer; the Flask app additionally checks the `Remote-User` and `Remote-Groups` headers via `@require_auth`. @@ -759,13 +759,13 @@ Take LED colors from the model's quick start guide. See the comment at the top o ### Known Limitations -- Single gunicorn worker (`--workers 1`) — required because `db.py` uses thread-local +- Single gunicorn worker (`--workers 1`): required because `db.py` uses thread-local connection reuse (one connection per thread). Multiple workers would each have their own connection, which is fine, but the thread-local optimisation only helps within one worker. -- No CSRF tokens on API endpoints — mitigated by Authelia session cookies being +- No CSRF tokens on API endpoints: mitigated by Authelia session cookies being `SameSite=Strict` and the site being admin-only. -- SSH collection via Pulse is synchronous — if Pulse is slow, the entire monitor cycle +- SSH collection via Pulse is synchronous: if Pulse is slow, the entire monitor cycle is delayed. The `pulse.timeout` config controls the max wait. - UniFi LLDP data is only as fresh as the last monitor poll (120s default). @@ -777,11 +777,11 @@ Take LED colors from the model's quick start guide. See the comment at the top o |---|---|---| | `lint.yml` (python-lint) | flake8 on all `.py` files | Every push and PR | | `lint.yml` (js-lint) | ESLint on `static/` | Every push and PR | -| `test.yml` | pytest — `diagnose.py`, `modem.py`, and `monitor.py` alert logic | Every push and PR | +| `test.yml` | pytest: `diagnose.py`, `modem.py`, and `monitor.py` alert logic | Every push and PR | | `security.yml` | bandit `-ll` (medium+ severity) | Every push, PR, and weekly Monday 6am | | `deploy` job in `lint.yml` | Calls the `gandalf-deploy` webhook on CT157 (10.10.10.61) | Push to `main` only, after both lint jobs pass | -Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge. +Branch protection is enabled on `main`: both lint jobs must pass before any PR can merge. Tests live in `tests/`: - `test_diagnose.py`: `DiagnosticsRunner` static methods (`build_ssh_command`, `parse_output`, diff --git a/app.py b/app.py index 5ea1d2e..e110718 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -"""Gandalf – Global Advanced Network Detection And Link Facilitator. +"""Gandalf: Global Advanced Network Detection And Link Facilitator. Flask web application serving the monitoring dashboard and suppression management UI. Authentication via Authelia forward-auth headers. @@ -63,7 +63,7 @@ def inject_config(): # In-memory diagnostic job store { job_id: { status, result, created_at } } _diag_jobs: dict = {} _diag_lock = threading.Lock() -# Per-user rate-limit: { username: [epoch_float, ...] } — cleaned inside _diag_lock +# Per-user rate-limit: { username: [epoch_float, ...] }: cleaned inside _diag_lock _diag_rate: dict = {} @@ -80,7 +80,7 @@ def _purge_old_jobs_loop(): for jid, j in list(_diag_jobs.items()): if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff: j['status'] = 'done' - j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned — no activity for 5 minutes.'} + j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned: no activity for 5 minutes.'} logger.error(f'Diagnostic job {jid} stuck (no activity for 5 min); marked done/error') @@ -201,7 +201,7 @@ def require_auth(f): f'only auth.trusted_proxies may send them' ) return ( - '

401 – Not authenticated

' + '

401: Not authenticated

' '

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

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

403 – Access denied

' + f'

403: Access denied

' f'

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

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