diff --git a/README.md b/README.md index c5e714a..b3dcceb 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,14 @@ 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. +- **More drawn fronts**: the Sliger CX4712 with its red, white or blue perforated front fitted (as in + the rack photo, drive cages faint behind the mesh), and the UniFi brush panel + (UACC-Rack-Panel-Brush-1U, from Ubiquiti's product images). +- **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..9921c94 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -37,10 +37,15 @@ CHASSIS = { 'uacc-patch-24': 'UniFi Patch Panel Blank 24 (UACC-Rack-Panel-Patch-Blank-24)', 'usw-pro-max-24-poe': 'UniFi Switch Pro Max 24 PoE (until UniFi reports it)', 'usw-pro-max-48-poe': 'UniFi Switch Pro Max 48 PoE (until UniFi reports it)', - 'sliger-cx4712': 'Sliger CX4712 4U (10-bay)', + 'sliger-cx4712': 'Sliger CX4712 4U (10-bay, faceplate off)', + 'sliger-cx4712-red': 'Sliger CX4712 4U, red front', + 'sliger-cx4712-white': 'Sliger CX4712 4U, white front', + 'sliger-cx4712-blue': 'Sliger CX4712 4U, blue front', + 'uacc-brush-1u': 'UniFi Rack Panel Brush 1U (UACC-Rack-Panel-Brush-1U)', '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..feab522 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. // @@ -106,12 +106,107 @@ const RACK_FRONTS = { }, }, + // Sliger CX4712 with its coloured front fitted, as in the rack photo (wiki: rack-2026.jpg): + // a hex-perforated panel over most of the face, the drive cages faintly visible through it, + // black rack ears, and a power button over two USB ports at the top right. + 'sliger-cx4712-red': sligerFront('#c8161d', '#e9e5e0'), + 'sliger-cx4712-white': sligerFront('#eceae6', '#9aa0a6'), + 'sliger-cx4712-blue': sligerFront('#1f5fae', '#e9e5e0'), + + // Ubiquiti UACC-Rack-Panel-Brush-1U, from Ubiquiti's product images (store.ui.com, UACC-Rack-Panel + // variant Brush 1U): a light silver face with separate ear plates (two rounded slots each), a + // recessed chamfered frame around the opening (shadowed along the top, bright bevel along the + // bottom), and two rows of black bristles meeting at a seam along the middle. + 'uacc-brush-1u': { + label: 'UniFi Rack Panel Brush 1U (UACC-Rack-Panel-Brush-1U)', units: 1, width: 1100, height: 105, + draw() { + // Bristles: each row is shaded root to tip (dark where it leaves the frame, lighter where the + // two rows meet), with a faint strand texture on top. Strong individual strands alias into hard + // stripes at rack size, so the texture stays low-contrast and the shading carries the look. + // The opening runs almost the full width between the ear plates, as in Ubiquiti's image + const strands = (y0, y1, seed) => Array.from({ length: 172 }, (_, i) => { + const n = (i * 7919 + seed * 104729) % 97; + return ``; + }).join(''); + const slot = x => ` + `; + return ` + + + + + + + + + + + + + + + ${slot(16)}${slot(1084)} + + + + + + + ${strands(31, 53, 1)}${strands(54, 76, 2)} + `; + }, + }, + // Generic servers, for gear without a specific drawing 'server-1u': genericServer(1, 4), 'server-2u': genericServer(2, 12), 'server-4u': genericServer(4, 24), }; +// A rack handle on a Sliger ear: a vertical bar standing off the ear on two mounts +function sligerHandle(x) { + const top = 70, bottom = 350; + return ` + + + `; +} + +function sligerFront(colour, hole) { + const W = 1100, H = 420; + // Hex perforations: a pattern of hexagons cut out of the panel through a mask + const hex = (cx, cy, r) => Array.from({ length: 6 }, (_, i) => { + const a = Math.PI / 6 + i * Math.PI / 3; + return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`; + }).join(' '); + // Hole size and pitch from the rack photo: about 20 rows of holes over the panel's height + const r = 6.9, dx = 18, dy = 15.6; + const holes = []; + for (let row = 0, y = 46; y < H - 34; row++, y += dy) { + for (let x = 82 + (row % 2 ? dx / 2 : 0); x < 925; x += dx) holes.push(``); + } + const bays = Array.from({ length: 10 }, (_, i) => ``).join(''); + // The cages show only faintly through the mesh + return { + label: 'Sliger CX4712 4U (10-bay)', units: 4, width: W, height: H, + draw({ status }) { + const id = `sl-${colour.slice(1)}`; + return `${holes.join('')} + ${bays} + + + ${sligerHandle(14)}${sligerHandle(W - 40)} + + + + + + ${usbA(977, 146)}${usbA(977, 168)}${usbC(980, 192)}`; + }, + }; +} + function genericServer(units, bays) { const H = 105 * units; return { @@ -256,42 +351,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/static/style.css b/static/style.css index 0313b95..23dcd59 100644 --- a/static/style.css +++ b/static/style.css @@ -1630,3 +1630,21 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .cx-tab { fill: none; stroke: #c9ccd0; stroke-width: 9; stroke-linejoin: round; } .cx-link { stroke: #101418; stroke-width: 3; } .fp-speedbar { fill: #b3b5ba; } +.rf-sl-inside { fill: #141619; } +.rf-sl-ear { fill: #17181a; } +.rf-sl-edge { fill: none; stroke: rgba(0,0,0,.35); stroke-width: 2; } +.rf-sl-button { fill: #c9ccd0; stroke: #8a8e93; stroke-width: 2; } +.rf-sl-bay { opacity: .45; } +.rf-sl-io { fill: #0e0f11; } +.rf-sl-mount { fill: #0c0d0f; } +.rf-sl-handle { fill: #3a3e44; stroke: #0a0b0c; stroke-width: 2; } +.rf-sl-handle-shine { fill: #6b717a; } +.ua-ear-plate { fill: #d3d5d8; stroke: #b2b4b8; stroke-width: 1; } +.ua-ear-seam { fill: #9fa1a6; } +.ua-ear-slot { fill: #2a2c30; } +.ub-frame { fill: #bfc1c5; stroke: #a5a7ac; stroke-width: 1; } +.ub-frame-shadow { fill: #8c8e93; } +.ub-frame-bevel { fill: #f1f2f3; } +.ub-opening { fill: #0d0e10; } +.ub-seam { fill: #050506; opacity: .8; } +.ub-strand { fill: #000; } 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..6d49bb2 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -369,7 +369,9 @@ 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', 'uacc-brush-1u', 'sliger-cx4712-red', 'sliger-cx4712-white', + 'sliger-cx4712-blue'} <= set(cs.CHASSIS) class TestFacingAndSlots: