Rack: gear resting on other gear, and live desk switches on shelves
Lint / Python (flake8) (push) Failing after 47s
Lint / JS (eslint) (push) Successful in 17s
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m26s
Lint / Notify on failure (push) Successful in 7s
Test / Python Tests (pytest) (push) Successful in 2m2s

A USW Flex Mini lies on top of the NVR. A shelf item with Front drawing
"No shelf: gear resting on what is below" draws no tray or ears, so its
contents sit directly on the item under it.

UniFi desk switches on a shelf (Flex Mini, Flex) are drawn with their live
faceplate, scaled to their real width from Ubiquiti's tech specs (107 mm and
122.5 mm), with working ports. faceplateSvg() returns a faceplate's SVG on
its own so it can be placed inside the shelf drawing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 01:08:42 -05:00
co-authored by Claude Opus 5.5
parent a0e062493d
commit d8e827c88b
8 changed files with 83 additions and 33 deletions
+5 -1
View File
@@ -154,7 +154,11 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
enp2s0 (left) and enp3s0 (right), and the status glow follows the host. The "ZimaBoard 832 +
ConnectX-3" drawing adds the single-port SFP+ card lying flat in the side PCIe slot, as in the rack,
with a DAC attached; its round dot is Gandalf's link mark for enp1s0 (the slot is PCI bus 1), not an
LED on the card. Anything else is a labelled box.
LED on the card. UniFi desk switches (Flex Mini, Flex) on a shelf are drawn with their live faceplate
at their real width. Anything else is a labelled box.
- **Gear resting on other gear**: a shelf item with Front drawing "No shelf: gear resting on what is
below" draws no tray, so a switch lying on top of the NVR is a "shelf" in the unit above the NVR whose
contents sit directly on it.
- **Racks**: admins name a rack and set its height with **Rack settings** on the page, and add more with
**+ Rack** (a tab per rack). Renaming a rack moves its items; an empty rack can be deleted. Each item
has a rack (empty means the first). Until a rack is saved, racks come from config:
+2 -1
View File
@@ -1210,7 +1210,8 @@ def api_rack():
'status': _device_status(dev(c), snapshot) if dev(c) in known else '',
'model': next((u.get('model') for u in snapshot.get('unifi') or [] if u.get('name') == dev(c)), '')
or '', 'notes': c.get('notes') or '', 'chassis': c.get('chassis') or '',
'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot)}
'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot),
'device': dev(c), 'ports': (switches.get(dev(c)) or {}).get('ports') or {}}
for c in sorted(on_shelf.get(name, []), key=lambda c: (c.get('slot') or 999, c['name'].lower()))
]
out.append(entry)
+1
View File
@@ -41,6 +41,7 @@ CHASSIS = {
'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)',
'zimaboard-832': 'IceWhale ZimaBoard 832 (shelf gear)',
'zimaboard-832-cx3': 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+ in PCIe (shelf gear)',
'resting': 'No shelf: gear resting on what is below (for a shelf item)',
'server-1u': 'Generic 1U server',
'server-2u': 'Generic 2U server',
'server-4u': 'Generic 4U server',
+12 -6
View File
@@ -595,6 +595,15 @@ function faceplateLegendHtml(spec, marks) {
// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text},
// rack? (draw only the front, spec.rackView, as mounted in a rack) }
function renderFaceplate(model, ctx) {
const { svg, marks } = faceplateSvg(model, ctx);
return `<div class="fp-wrap">
<div class="fp-frame ${FACEPLATES[model].frameClass}">${svg}</div>
</div>${faceplateLegendHtml(FACEPLATES[model], marks)}`;
}
// The faceplate's SVG on its own, for drawing inside another picture (a shelf). `attrs` adds
// attributes to the <svg> (x, y, width, height to place it). Returns { svg, marks }.
function faceplateSvg(model, ctx, attrs = '') {
const spec = FACEPLATES[model];
const anyPort = Object.values(ctx.portMap)[0];
const marks = new Set();
@@ -612,9 +621,7 @@ function renderFaceplate(model, ctx) {
const outlets = (spec.outlets || []).map(o => faceplateOutletSvg(o, outletData[o.idx])).join('');
const body = spec.noBody ? ''
: `<rect class="fp-body" width="${spec.width}" height="${spec.height}" rx="3"/>`;
return `<div class="fp-wrap">
<div class="fp-frame ${spec.frameClass}">
<svg class="fp-svg" viewBox="${((ctx.rack && spec.rackView) || [0, 0, spec.width, spec.height]).join(' ')}" role="group" aria-label="${lt.escHtml(spec.label)} front panel">
const svg = `<svg class="fp-svg" ${attrs} viewBox="${((ctx.rack && spec.rackView) || [0, 0, spec.width, spec.height]).join(' ')}" role="group" aria-label="${lt.escHtml(spec.label)} front panel">
<defs>
<linearGradient id="fp-metal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e4e4e6"/><stop offset=".08" stop-color="#d6d6d8"/>
@@ -628,9 +635,8 @@ function renderFaceplate(model, ctx) {
${spec.decor({ online: ctx.online })}
${outlets}
${ports}
</svg>
</div>
</div>${faceplateLegendHtml(spec, marks)}`;
</svg>`;
return { svg, marks };
}
// Where an fp-scroll panel should start: its port area
+50 -22
View File
@@ -1,5 +1,5 @@
'use strict';
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR */
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR, DESK_FACEPLATES */
// 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.
//
@@ -256,42 +256,70 @@ const SHELF_GEAR = {
},
};
// UniFi desk gear drawn from its live faceplate when it sits on a shelf: the real width of the
// face (Ubiquiti tech specs) and the span of that face in the faceplate drawing, to scale it.
const DESK_FACEPLATES = {
USMINI: { mm: 107, body: [2, 661], label: 'UniFi Switch Flex Mini' }, // 107 x 70 x 21 mm
USF5P: { mm: 122.5, body: [66, 488], label: 'UniFi Switch Flex' }, // 122.5 x 107.1 x 28 mm
};
// A shelf at true scale (1100 units = 482.6 mm, a 19" panel) with what sits on it, left to right.
// Gear with a SHELF_GEAR drawing is drawn to its real width; anything else is a labelled box.
function shelfSvg(item, esc) {
// Gear with a SHELF_GEAR drawing, or UniFi desk gear with a live faceplate, is drawn to its real
// width; anything else is a labelled box. A shelf with Front drawing "resting" has no tray: its gear
// lies directly on whatever is below it (a switch on top of the NVR).
function shelfSvg(item, esc, faceplate) {
const units = item.rack_height || 1;
const W = 1100, H = 105 * units, perMm = W / 482.6;
const floor = H - 20;
const resting = item.chassis === 'resting';
const floor = resting ? H - 3 : H - 20;
let x = 64;
const things = (item.contents || []).map(c => {
const gear = SHELF_GEAR[c.chassis];
const w = gear ? gear.mm * perMm : 150;
const k = gear ? w / gear.width : 1;
const h = gear ? gear.height * k : 58;
const desk = !gear && faceplate && DESK_FACEPLATES[c.model];
let w, h, body;
if (gear) {
w = gear.mm * perMm;
const k = w / gear.width;
h = gear.height * k;
body = `<g transform="translate(${x} ${floor - h}) scale(${k})">${gear.draw(c)}</g>`;
} else if (desk) {
// Scale the faceplate so its body spans the switch's real width, then place the whole drawing
const spec = faceplate.spec(c.model);
const k = (desk.mm * perMm) / (desk.body[1] - desk.body[0]);
w = desk.mm * perMm;
h = spec.height * k;
body = faceplate.svg(c, `x="${x - desk.body[0] * k}" y="${floor - h}" width="${spec.width * k}" height="${h}"`);
} else {
w = 150;
h = 58;
body = `<rect class="sh-box" x="${x}" y="${floor - h}" width="${w}" height="${h}" rx="4"/>
<text class="sh-box-name" x="${x + w / 2}" y="${floor - h / 2 + 5}" text-anchor="middle">${esc(c.name)}</text>`;
}
const top = floor - h;
const nics = (c.interfaces || []).map(n => `${n.name} ${n.up ? 'up' : 'down'}`).join(', ');
const tip = [c.name, gear ? gear.label : c.kind, c.status, nics, gear && gear.specs].filter(Boolean).join(' · ');
const body = gear
? `<g transform="translate(${x} ${top}) scale(${k})">${gear.draw(c)}</g>`
: `<rect class="sh-box" x="${x}" y="${top}" width="${w}" height="${h}" rx="4"/>
<text class="sh-box-name" x="${x + w / 2}" y="${top + h / 2 + 5}" text-anchor="middle">${esc(c.name)}</text>`;
const out = `<a class="sh-thing" href="${esc(c.url)}"><title>${esc(tip)}</title>
const label = gear ? gear.label : (desk ? desk.label : c.kind);
const tip = [c.name, label, c.status, nics, gear && gear.specs].filter(Boolean).join(' · ');
// A live faceplate's ports are links of their own, so it isn't wrapped in one
const open = desk ? '<g class="sh-thing">' : `<a class="sh-thing" href="${esc(c.url)}">`;
const tagY = resting ? top - 16 : floor + 5;
const out = `${open}<title>${esc(tip)}</title>
${body}
<rect class="sh-hit" x="${x - 4}" y="${top - 4}" width="${w + 8}" height="${h + 8}" rx="4"/>
<rect class="sh-tag" x="${x + w / 2 - 46}" y="${floor + 5}" width="92" height="12" rx="2"/>
<text class="sh-tag-text" x="${x + w / 2}" y="${floor + 14}" text-anchor="middle">${esc(c.name)}</text>
</a>`;
${desk ? '' : `<rect class="sh-hit" x="${x - 4}" y="${top - 4}" width="${w + 8}" height="${h + 8}" rx="4"/>`}
<rect class="sh-tag" x="${x + w / 2 - 46}" y="${tagY}" width="92" height="12" rx="2"/>
<text class="sh-tag-text" x="${x + w / 2}" y="${tagY + 9}" text-anchor="middle">${esc(c.name)}</text>
${desk ? '</g>' : '</a>'}`;
x += w + 26;
return out;
}).join('');
const tray = resting ? '' : `${ears(W, H, 'rf-ear-dark')}
<rect class="sh-back" x="36" y="6" width="${W - 72}" height="${floor - 6}"/>
<rect class="sh-floor" x="30" y="${floor}" width="${W - 60}" height="${H - floor - 2}" rx="2"/>
<rect class="sh-lip" x="30" y="${floor}" width="${W - 60}" height="3"/>`;
return `<svg class="rack-svg rack-shelf-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="group"
aria-label="${esc(item.name)}">
<title>${esc(item.name)}</title>
${ears(W, H, 'rf-ear-dark')}
<rect class="sh-back" x="36" y="6" width="${W - 72}" height="${floor - 6}"/>
<rect class="sh-floor" x="30" y="${floor}" width="${W - 60}" height="${H - floor - 2}" rx="2"/>
<rect class="sh-lip" x="30" y="${floor}" width="${W - 60}" height="3"/>
${things || `<text class="sh-empty" x="${W / 2}" y="${floor - 12}" text-anchor="middle">empty shelf</text>`}
${tray}
${things || `<text class="sh-empty" x="${W / 2}" y="${floor - 12}" text-anchor="middle">${resting ? 'nothing here' : 'empty shelf'}</text>`}
<text class="sh-name" x="46" y="18">${esc(item.name)}</text>
</svg>`;
}
+11 -2
View File
@@ -1,5 +1,5 @@
'use strict';
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
/* global FACEPLATES, renderFaceplate, faceplateSvg, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
// 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.
@@ -145,6 +145,15 @@ function plateHtml(item) {
</div>`;
}
// Live faceplates for UniFi desk gear sitting on a shelf (a Flex Mini on top of the NVR)
const shelfFaceplates = {
spec: model => FACEPLATES[model],
svg: (c, attrs) => faceplateSvg(c.model, {
swName: c.device || c.name, portMap: portMapOf(c.ports), outlets: [], online: c.status === 'up',
titleFor: (port, idx) => portTitle(port, idx),
}, attrs).svg,
};
function backHtml(item) {
// A full-depth item seen from the rear: its back, not its faceplate
return `<div class="rack-plate rack-back">
@@ -161,7 +170,7 @@ function itemHtml(item) {
else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item);
else if (item.kind === 'patch_panel') body = panelSvg(item);
else if (item.kind === 'shelf') body = shelfSvg(item, esc);
else if (item.kind === 'shelf') body = shelfSvg(item, esc, shelfFaceplates);
else body = plateHtml(item);
return `<div class="rack-item rack-kind-${esc(item.kind)}" data-item="${esc(item.id)}">${body}</div>`;
}
+1
View File
@@ -552,6 +552,7 @@ class TestRackFacesAndSettings:
assert [(c['name'], c['slot'], c['chassis']) for c in things] == [('b-box', 1, 'zimaboard-832'),
('large1', 2, '')]
assert things[1]['interfaces'] == [{'name': 'eth0', 'up': True}]
assert things[0]['device'] == 'b-box' and things[0]['ports'] == {}
def test_saved_racks_replace_config(self, client):
self._items()
+1 -1
View File
@@ -369,7 +369,7 @@ class TestChassis:
def test_every_drawing_has_a_label(self):
assert all(cs.CHASSIS.values())
assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24',
'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3'} <= set(cs.CHASSIS)
'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3', 'resting'} <= set(cs.CHASSIS)
class TestFacingAndSlots: