Rack: NVR Pro as a real faceplate; Sliger and Rosewill touch-ups
Lint / JS (eslint) (push) Successful in 18s
Security / Python Security (bandit) (push) Successful in 1m45s
Lint / Python (flake8) (push) Successful in 1m25s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Python (flake8) (pull_request) Successful in 52s
Lint / JS (eslint) (pull_request) Successful in 6s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Test / Python Tests (pytest) (push) Successful in 1m18s
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 53s

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 23:31:19 -05:00
co-authored by Claude Opus 5.5
parent 0f4e129c20
commit 565fdd81de
5 changed files with 56 additions and 41 deletions
+4 -3
View File
@@ -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
+29
View File
@@ -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) => `<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)}${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('')}
<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: {
+12 -32
View File
@@ -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) => `<rect class="rf-unvr-bay" x="${x}" y="${y}" width="${w}" height="76" rx="2"/>
<rect class="rf-unvr-rail" x="${x}" y="${y}" width="5" height="76"/>
<circle class="rf-unvr-dot" cx="${x + w - 30}" cy="${y + 38}" r="2.6"/>
<text class="rf-small" x="${x + w / 2}" y="${y + 90}" text-anchor="middle">${n}</text>`;
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 => `<rect class="rf-unvr-strip" x="${x}" y="${y}" width="196" height="4" rx="2"/>`).join('');
return `<rect class="rf-unvr-body" width="1167" height="230" rx="3"/>
${strip(9)}${strip(216)}
<rect class="rf-screen" x="14" y="35" width="61" height="60" rx="4"/>
<circle class="rf-screen-ring" cx="44" cy="65" r="13" style="stroke:${frontLed(status)}"/>
<text class="rf-small rf-dark" x="44" y="113" text-anchor="middle">NVR Pro</text>
${top}${bottom}
<rect class="rf-port" x="1018" y="52" width="44" height="38" rx="2"/>
<rect class="rf-port-led" x="1050" y="55" width="9" height="5" style="fill:${frontLed(status)}"/>
<rect class="rf-port" x="1070" y="65" width="40" height="27" rx="2"/>
<text class="rf-small" x="1040" y="46" text-anchor="middle">1</text>
<text class="rf-small" x="1090" y="46" text-anchor="middle">2</text>
<circle class="rf-reset" cx="1128" cy="219" r="3"/><text class="rf-small" x="1134" y="222">RESET</text>`;
},
},
// 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)}
<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"/>
@@ -116,9 +90,12 @@ const RACK_FRONTS = {
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"/>
<rect class="rf-mesh" x="80" y="32" width="848" height="356" rx="4"/>
${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"/>
@@ -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];
+6 -4
View File
@@ -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);
+5 -2
View File
@@ -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; }