diff --git a/README.md b/README.md index c5e714a..993e0f3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app.py b/app.py index b18fba1..4b49211 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/cutsheet.py b/cutsheet.py index 3f13b1d..9111bfd 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -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', diff --git a/static/faceplates.js b/static/faceplates.js index fc33088..3f04150 100644 --- a/static/faceplates.js +++ b/static/faceplates.js @@ -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 `
+
${svg}
+
${faceplateLegendHtml(FACEPLATES[model], marks)}`; +} + +// The faceplate's SVG on its own, for drawing inside another picture (a shelf). `attrs` adds +// attributes to the (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 ? '' : ``; - return `
-
- + const svg = ` @@ -628,9 +635,8 @@ function renderFaceplate(model, ctx) { ${spec.decor({ online: ctx.online })} ${outlets} ${ports} - -
-
${faceplateLegendHtml(spec, marks)}`; + `; + return { svg, marks }; } // Where an fp-scroll panel should start: its port area diff --git a/static/fronts.js b/static/fronts.js index c068120..f7a38b6 100644 --- a/static/fronts.js +++ b/static/fronts.js @@ -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 = `${gear.draw(c)}`; + } 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 = ` + ${esc(c.name)}`; + } 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 - ? `${gear.draw(c)}` - : ` - ${esc(c.name)}`; - const out = `${esc(tip)} + 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 ? '' : ``; + const tagY = resting ? top - 16 : floor + 5; + const out = `${open}${esc(tip)} ${body} - - - ${esc(c.name)} - `; + ${desk ? '' : ``} + + ${esc(c.name)} + ${desk ? '' : ''}`; x += w + 26; return out; }).join(''); + const tray = resting ? '' : `${ears(W, H, 'rf-ear-dark')} + + + `; return ` ${esc(item.name)} - ${ears(W, H, 'rf-ear-dark')} - - - - ${things || `empty shelf`} + ${tray} + ${things || `${resting ? 'nothing here' : 'empty shelf'}`} ${esc(item.name)} `; } diff --git a/static/rack.js b/static/rack.js index 55aa62e..0453185 100644 --- a/static/rack.js +++ b/static/rack.js @@ -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) { `; } +// 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 `
@@ -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 `
${body}
`; } diff --git a/tests/test_app.py b/tests/test_app.py index 9fb9186..8d83b6a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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() diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index 48e02f5..48bbe1e 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -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: