From 0f4e129c20b72c560306378b4369d40c4561e48e Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Nov 2026 23:25:02 -0500 Subject: [PATCH] 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