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
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user