From a9de175d906f0dbcd90e9aed6199107c41508d8d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 00:12:07 -0500 Subject: [PATCH] Rack: ports-facing separate from posts, three views, ZimaBoard 832 shelves to scale Gear on the rear posts usually still faces the front (the StarTech's RPDU and ZimaBoard shelf), so "which posts" and "which way the ports face" are now separate fields (mount, facing). The page has three views: Front (front posts), Rear posts (rear-post gear seen from the front, i.e. what is behind the front gear) and Rear (from behind). An item seen from the side it doesn't face shows its back. Facing defaults to front, so existing items keep their meaning. Shelves are drawn at true scale with their contents ordered by a new "Position on shelf". The IceWhale ZimaBoard 832 is traced from IceWhale's labelled photo of its port end (ZimaDocs): Mini-DP, two RJ45-over-USB 3.0 stacks with link/activity LEDs, DC jack, gunmetal case, gold PCB edge and black base. Its RJ45 link lights follow the host's physical NICs and the status glow follows the host. New cutsheet_items columns facing and slot, added on first use. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +++++-- app.py | 25 ++++++++-- cutsheet.py | 34 ++++++++++---- db.py | 8 +++- static/cutsheet.js | 14 +++--- static/fronts.js | 101 +++++++++++++++++++++++++++++++++++++++- static/rack.js | 31 +++++------- static/style.css | 33 +++++++++++++ templates/cutsheet.html | 15 +++++- templates/rack.html | 8 +++- tests/test_app.py | 30 +++++++++--- tests/test_cutsheet.py | 20 ++++++++ 12 files changed, 281 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index b41cd1f..03a7338 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,18 @@ The rack front, drawn from the rack units set on cutsheet items, top first. 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 - 4-post rack a front and a rear item can share a unit; a full-depth item (a server) takes it on both - sides, and the rear view shows its back. Items on a shelf take the shelf's side. The Front / Rear switch - flips the drawing (`?face=rear`). +- **Posts, facing and views**: each item hangs on the front posts, the rear posts, or both (full + depth), and its ports face the front or the rear. On a 4-post rack a front-post and a rear-post item + can share a unit; a full-depth item takes it on both. The page has three views: **Front** (the front + posts), **Rear posts** (gear on the rear posts seen from the front, i.e. what sits behind the front + gear, such as a PDU or a shelf mounted from the rear but facing forward) and **Rear** (from behind). + An item seen from the side it doesn't face shows its back. Items on a shelf take the shelf's posts + and facing. +- **Shelves to scale**: a shelf draws its contents left to right by **Position on shelf**, at true scale + (a 19" panel is 482.6 mm). Gear with a drawing, such as the IceWhale ZimaBoard 832 (traced from + IceWhale's labelled photo of its port end), shows its real ports: each RJ45's link light follows the + host's physical NICs in name order (first NIC on the left jack), and the status glow follows the + host. Anything else is a labelled box. - **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: @@ -724,7 +732,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o ### Database Schema Notes -- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount` and `chassis` columns; they are added automatically on first use +- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount`, `chassis`, `facing` and `slot` columns; they are added automatically on first use - `cutsheet_racks`: racks saved on the rack page (name, height, notes) - `device_links`: each device's inventory URL, by device name; created with the cutsheet tables - `network_events`: one row per active event; `resolved_at` is set when recovered, and diff --git a/app.py b/app.py index e110718..b18fba1 100644 --- a/app.py +++ b/app.py @@ -1105,6 +1105,19 @@ def _racks(items: list) -> list: return racks +_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun') + + +def _physical_interfaces(name: str, snapshot: dict) -> list: + """A host's physical NICs in name order, with their state: [{'name', 'up'}]. + + Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack). + """ + ifaces = ((snapshot.get('hosts') or {}).get(name) or {}).get('interfaces') or {} + return [{'name': n, 'up': str(state).lower() == 'up'} for n, state in sorted(ifaces.items()) + if not n.startswith(_VIRTUAL_IFACE)] + + def _device_status(name: str, snapshot: dict) -> str: host = (snapshot.get('hosts') or {}).get(name) if host: @@ -1123,8 +1136,8 @@ def api_rack(): Query: rack (name; default the first rack), face (front|rear). """ face = request.args.get('face', 'front') - if face not in ('front', 'rear'): - return jsonify({'error': 'face must be front or rear'}), 400 + if face not in cutsheet.RACK_VIEWS: + return jsonify({'error': f'face must be one of {", ".join(cutsheet.RACK_VIEWS)}'}), 400 view = _cutsheet_view() link_stats = _state_json('link_stats') switches = link_stats.get('unifi_switches') or {} @@ -1145,7 +1158,9 @@ def api_rack(): for r in racks: racked = [i for i in view['items'] if not i.get('shelf') and i.get('rack_u') and rack_of(i) == r['name']] r['count'] = len(racked) + # How many items each view shows: rear-post gear is what "behind" and "rear" add r['rear'] = sum(1 for i in racked if i.get('mount') in ('rear', 'full')) + r['behind'] = sum(1 for i in racked if i.get('mount') == 'rear') for cable in view['cables']: cable['run'] = [h['cable_id'] for h in cable['path'] if 'cable_id' in h] marks = cutsheet.port_marks(view['cables']) @@ -1189,12 +1204,14 @@ def api_rack(): 'outlets': (unifi_info.get(device) or {}).get('outlets') or [], 'online': online.get(device, True)} if item['kind'] == 'shelf': + # Left to right: by position on the shelf, then by name entry['contents'] = [ {'id': c['id'], 'name': c['name'], 'kind': c['kind'], 'url': url(c), '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 ''} - for c in sorted(on_shelf.get(name, []), key=lambda c: c['name'].lower()) + or '', 'notes': c.get('notes') or '', 'chassis': c.get('chassis') or '', + 'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot)} + for c in sorted(on_shelf.get(name, []), key=lambda c: (c.get('slot') or 999, c['name'].lower())) ] out.append(entry) return jsonify({ diff --git a/cutsheet.py b/cutsheet.py index cd9be22..d471e9d 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -25,6 +25,11 @@ ITEM_KINDS = ('patch_panel', 'wall_jack', 'device', 'shelf', 'other') # Which side of a 4-post rack an item is mounted from. A front and a rear item can share # a unit; a full-depth one (a server) takes the unit on both sides. MOUNTS = ('front', 'rear', 'full') +# Which way an item's ports face. Gear on the rear posts usually still faces the front. +FACINGS = ('front', 'rear') +# Rack views: the front posts seen from the front, the rear posts seen from the front +# (what is behind the front gear), and the rack seen from behind +RACK_VIEWS = ('front', 'behind', 'rear') RACK_UNITS_MAX = 60 # Drawn fronts for gear without a live faceplate (keys of RACK_FRONTS in static/fronts.js) CHASSIS = { @@ -32,6 +37,7 @@ CHASSIS = { 'uacc-patch-24': 'UniFi Patch Panel Blank 24 (UACC-Rack-Panel-Patch-Blank-24)', 'sliger-cx4712': 'Sliger CX4712 4U (10-bay)', 'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)', + 'zimaboard-832': 'IceWhale ZimaBoard 832 (shelf gear)', 'server-1u': 'Generic 1U server', 'server-2u': 'Generic 2U server', 'server-4u': 'Generic 4U server', @@ -110,11 +116,17 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict: 'device': _text(data, 'device'), 'mount': _text(data, 'mount', 10) or 'front', 'chassis': _text(data, 'chassis', 40), + 'facing': _text(data, 'facing', 10) or 'front', + 'slot': _int(data, 'slot', 1, 99, 'Position on the shelf'), 'shelf': _text(data, 'shelf', NAME_MAX), 'notes': _text(data, 'notes', NOTES_MAX), } if item['chassis'] and item['chassis'] not in CHASSIS: raise ValidationError(f'Unknown front drawing {item["chassis"]}') + if item['facing'] not in FACINGS: + raise ValidationError(f'facing must be one of {", ".join(FACINGS)}') + if item['slot'] and not item['shelf']: + raise ValidationError('A position applies only to an item on a shelf') if item['mount'] not in MOUNTS: raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}') if item['shelf']: @@ -127,6 +139,7 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict: raise ValidationError('An item on a shelf takes its rack position from the shelf; clear the rack unit') item['rack'] = '' # the shelf's rack and side item['mount'] = 'front' + item['facing'] = 'front' if item['rack_height'] and not item['rack_u']: raise ValidationError('Set a rack unit to go with the height') if item['rack_u'] and not item['rack_height']: @@ -602,15 +615,20 @@ def _sides(item: dict) -> Tuple[str, ...]: def rack_layout(items: List[dict], units: int, face: str = 'front') -> dict: - """Place items that have a rack unit, top of the rack first, as seen from one face. + """Place items that have a rack unit, top of the rack first, for one view. - Front and rear items may share a unit; full-depth items take it on both sides. - An item that would share a unit and side with one already placed, or runs past the - top of the rack, is reported (whichever face is asked for) rather than drawn over another. + Front-post and rear-post items may share a unit; full-depth items take it on both. + Views (RACK_VIEWS): 'front' shows the front posts from the front; 'behind' shows the + rear posts from the front (what sits behind the front gear); 'rear' shows the rear posts + from behind. Full-depth items appear in all three. + An item that would share a unit and post with one already placed, or runs past the + top of the rack, is reported (whatever the view) rather than drawn over another. Returns {'placed': [item + 'rack_top' + 'through'], 'overlaps': [{'name', 'units', 'with'}], - 'outside': [name]}, where 'through' marks a full-depth item seen from the side it isn't faced to - (its back, for the rear view). + 'outside': [name]}, where 'through' marks an item seen from the side it doesn't face + (its back, rather than its ports). """ + side = 'front' if face == 'front' else 'rear' + looking = 'rear' if face == 'rear' else 'front' taken: Dict[tuple, str] = {} placed, overlaps, outside = [], [], [] racked = [i for i in items if i.get('rack_u')] @@ -630,8 +648,8 @@ def rack_layout(items: List[dict], units: int, face: str = 'front') -> dict: continue for c in cells: taken[c] = item['name'] - if face in sides: - placed.append(dict(item, rack_top=high, through=item.get('mount') == 'full' and face == 'rear')) + if side in sides: + placed.append(dict(item, rack_top=high, through=(item.get('facing') or 'front') != looking)) return {'placed': placed, 'overlaps': overlaps, 'outside': outside} diff --git a/db.py b/db.py index 90eed8c..e55da2d 100644 --- a/db.py +++ b/db.py @@ -679,6 +679,8 @@ _CUTSHEET_SCHEMA = ( device VARCHAR(255) NOT NULL DEFAULT '', mount VARCHAR(10) NOT NULL DEFAULT 'front', chassis VARCHAR(40) NOT NULL DEFAULT '', + facing VARCHAR(10) NOT NULL DEFAULT 'front', + slot SMALLINT NULL, notes TEXT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -769,7 +771,7 @@ def set_device_link(name: str, inventory_url: str, user: str) -> None: ) _ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'rack', 'shelf', 'device', - 'mount', 'chassis', 'notes') + 'mount', 'chassis', 'facing', 'slot', 'notes') # Columns added to cutsheet_items after its first release: (name, definition) _ITEM_COLUMNS_ADDED = ( ('rack', "VARCHAR(100) NOT NULL DEFAULT ''"), @@ -778,6 +780,10 @@ _ITEM_COLUMNS_ADDED = ( ('device', "VARCHAR(255) NOT NULL DEFAULT ''"), ('mount', "VARCHAR(10) NOT NULL DEFAULT 'front'"), ('chassis', "VARCHAR(40) NOT NULL DEFAULT ''"), + # Which way the ports face, independent of which posts it hangs on + ('facing', "VARCHAR(10) NOT NULL DEFAULT 'front'"), + # Left-to-right position on a shelf + ('slot', 'SMALLINT NULL'), ) _CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side', 'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes') diff --git a/static/cutsheet.js b/static/cutsheet.js index db73a57..df9ee84 100644 --- a/static/cutsheet.js +++ b/static/cutsheet.js @@ -258,9 +258,9 @@ function renderRack() { } function rackText(i) { - if (i.shelf) return `on ${esc(i.shelf)}`; + if (i.shelf) return `on ${esc(i.shelf)}${i.slot ? `, position ${esc(i.slot)}` : ''}`; if (!i.rack_u) return '-'; - const side = { rear: ' rear', full: ' full depth' }[i.mount] || ''; + const side = ({ rear: ' rear posts', full: ' full depth' }[i.mount] || '') + (i.facing === 'rear' ? ', faces rear' : ''); const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}${side}`; return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where; } @@ -389,7 +389,7 @@ function openCable(cable, prefill) { function openItem(item) { const form = document.getElementById('cs-item-form'); form.reset(); - const data = Object.assign({ kind: 'patch_panel', mount: 'front' }, item || {}); + const data = Object.assign({ kind: 'patch_panel', mount: 'front', facing: 'front' }, item || {}); document.getElementById('cs-rack-list').innerHTML = (_cs.racks || []).map(r => `' + @@ -397,8 +397,8 @@ function openItem(item) { const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id)); form.shelf.innerHTML = '' + shelves.map(i => ``).join(''); - for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf', - 'chassis', 'location', 'notes']) { + for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'facing', + 'shelf', 'slot', 'chassis', 'location', 'notes']) { form[key].value = data[key] == null ? '' : data[key]; } if (!item) form.port_count.value = 24; @@ -451,8 +451,8 @@ async function saveCable(e) { async function saveItem(e) { e.preventDefault(); const form = e.target; - const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf', - 'chassis', 'location', 'notes']); + const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'facing', + 'shelf', 'slot', 'chassis', 'location', 'notes']); const id = form.id.value; await save(() => id ? lt.api.put(`/api/cutsheet/items/${encodeURIComponent(id)}`, body) : lt.api.post('/api/cutsheet/items', body), 'Saved', 'cs-item-modal'); diff --git a/static/fronts.js b/static/fronts.js index e8af622..499984d 100644 --- a/static/fronts.js +++ b/static/fronts.js @@ -1,5 +1,5 @@ 'use strict'; -/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */ +/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR */ // 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. // @@ -146,3 +146,102 @@ function rackFrontSvg(key, ctx) { ${spec.draw(ctx)} `; } + +// ── IceWhale ZimaBoard 832 (shelf gear) ───────────────────────────────── +// The port end, traced from IceWhale's labelled photo in ZimaDocs +// (source/images/Hardware-Interface-Introduction-/hardware-interface-introduction.webp), +// in that photo's pixel coordinates (the port end spans 862 x 415 px; 81.4 mm wide). +// Left to right: Mini-DisplayPort 1.2, two stacks of GbE RJ45 over USB 3.0, DC 12V 5.5x2.5mm, +// on a gunmetal case over a thin gold PCB edge and the black polycarbonate base. +const ZIMA = { width: 862, height: 415, mm: 81.4 }; + +function zimaRj45(x, y, link) { + const contacts = Array.from({ length: 8 }, (_, i) => ``).join(''); + return ` + + + ${contacts} + + `; +} + +function zimaUsb(x, y) { + return ` + + + ${Array.from({ length: 5 }, (_, i) => ``).join('')}`; +} + +function zimaFront(ctx) { + // ctx: { status, interfaces: [{name, up}] } - one RJ45 per NIC, left to right + const nics = ctx.interfaces || []; + const link = i => (nics[i] ? nics[i].up : ctx.status === 'up'); + const on = ctx.status === 'up' || ctx.status === 'degraded'; + const pads = [60, 105, 420, 470, 640, 700, 790, 835] + .map(x => ``).join(''); + return ` + + + ${on ? '' : ''} + ${pads} + + + + + + + ${zimaRj45(213, 47, link(0))}${zimaRj45(443, 47, link(1))} + ${zimaUsb(216, 205)}${zimaUsb(443, 205)} + + + + + `; +} + +const SHELF_GEAR = { + 'zimaboard-832': { + label: 'IceWhale ZimaBoard 832', mm: ZIMA.mm, width: ZIMA.width, height: ZIMA.height, draw: zimaFront, + specs: 'Intel Celeron N3450, 8GB LPDDR4, 32GB eMMC, 2x GbE, 2x USB 3.0, 2x SATA, PCIe 2.0 x4, 6W', + }, +}; + +// 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) { + const units = item.rack_height || 1; + const W = 1100, H = 105 * units, perMm = W / 482.6; + const floor = 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 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)} + ${body} + + + ${esc(c.name)} + `; + x += w + 26; + return out; + }).join(''); + return ` + ${esc(item.name)} + ${ears(W, H, 'rf-ear-dark')} + + + + ${things || `empty shelf`} + ${esc(item.name)} + `; +} diff --git a/static/rack.js b/static/rack.js index a023bd5..55aa62e 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 */ +/* global FACEPLATES, renderFaceplate, 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. @@ -93,18 +93,6 @@ function statusDot(status) { return status ? `` : ''; } -function shelfHtml(item) { - const contents = item.contents || []; - const things = contents.map(c => ` - - ${statusDot(c.status)}${esc(c.name)} - `).join(''); - return `
- ${esc(item.name)} -
${things || 'empty shelf'}
-
`; -} - // Ubiquiti UACC-Rack-Panel-Patch-Blank-24, laid out from the CC0 NetBox devicetype-library image // (elevation-images/Ubiquiti/ubiquiti-uacc-rack-panel-patch-blank-24.front.png): a grey steel face, // darker ears with oval holes, 24 square keystone openings in one row with the cable-support bar @@ -173,7 +161,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 = shelfHtml(item); + else if (item.kind === 'shelf') body = shelfSvg(item, esc); else body = plateHtml(item); return `
${body}
`; } @@ -216,14 +204,16 @@ function renderTabs(data) { return ``; }).join(''); - document.getElementById('rack-title').textContent = `${data.rack} · ${data.face}`; + const VIEW_TITLE = { front: 'front', behind: 'rear posts, from the front', rear: 'rear' }; + document.getElementById('rack-title').textContent = `${data.rack} · ${VIEW_TITLE[data.face] || data.face}`; document.querySelectorAll('.rack-face-btn').forEach(b => { const on = b.dataset.face === data.face; b.classList.toggle('active', on); b.setAttribute('aria-pressed', on); }); - const rear = (data.racks.find(r => r.name === data.rack) || {}).rear || 0; - document.querySelector('.rack-face-btn[data-face="rear"]').textContent = rear ? `Rear (${rear})` : 'Rear'; + const r = data.racks.find(x => x.name === data.rack) || {}; + document.querySelector('.rack-face-btn[data-face="behind"]').textContent = r.behind ? `Rear posts (${r.behind})` : 'Rear posts'; + document.querySelector('.rack-face-btn[data-face="rear"]').textContent = r.rear ? `Rear (${r.rear})` : 'Rear'; } function render(data) { @@ -362,7 +352,8 @@ function currentRack() { } function currentFace() { - return new URLSearchParams(location.search).get('face') === 'rear' ? 'rear' : 'front'; + const face = new URLSearchParams(location.search).get('face'); + return ['behind', 'rear'].includes(face) ? face : 'front'; } function setView(changes) { @@ -383,14 +374,14 @@ document.getElementById('rack-tabs').addEventListener('click', e => { document.querySelector('.rack-face').addEventListener('click', e => { const btn = e.target.closest('[data-face]'); - if (btn) setView({ face: btn.dataset.face === 'rear' ? 'rear' : '' }); + if (btn) setView({ face: btn.dataset.face === 'front' ? '' : btn.dataset.face }); }); async function load() { try { const q = new URLSearchParams(); if (currentRack()) q.set('rack', currentRack()); - if (currentFace() === 'rear') q.set('face', 'rear'); + if (currentFace() !== 'front') q.set('face', currentFace()); render(await lt.api.get(`/api/rack${q.toString() ? `?${q}` : ''}`)); } catch (err) { if (_rack) return; // keep the last drawing on a failed refresh diff --git a/static/style.css b/static/style.css index d3362af..90020a6 100644 --- a/static/style.css +++ b/static/style.css @@ -1584,3 +1584,36 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .rack-pp-bad .ua-mark { stroke: #e0312b; stroke-width: 3; } .rack-pp-doc .ua-mark { stroke: #4c5662; stroke-width: 2; } .rack-pp-port.rack-hl .ua-mark { fill: rgba(0,212,255,.25); stroke: #00b8e6; stroke-width: 3.5; } + +/* ── Shelves drawn to scale, and the ZimaBoard 832 (static/fronts.js) ─ */ +.sh-back { fill: #0f1215; } +.sh-floor { fill: #3a414b; } +.sh-lip { fill: #59636f; } +.sh-name { fill: var(--rk-muted, #8b97a5); font-size: 11px; font-family: Helvetica, Arial, sans-serif; } +.sh-empty { fill: var(--rk-muted, #8b97a5); font-size: 13px; } +.sh-box { fill: #d4d7dc; stroke: #8a929c; } +.sh-box-name { fill: #15191e; font-size: 13px; font-weight: 700; font-family: Helvetica, Arial, sans-serif; } +.sh-tag { fill: #111; stroke: #3a3f45; } +.sh-tag-text { fill: #e9edf1; font-size: 9px; font-weight: 700; font-family: Helvetica, Arial, sans-serif; } +.sh-hit { fill: transparent; stroke: transparent; stroke-width: 2.5; } +.sh-thing:hover .sh-hit, .sh-thing:focus-visible .sh-hit { stroke: #00b8e6; } +.zb-case { fill: #66625b; } +.zb-case-edge { fill: #807c74; } +.zb-case-top { fill: #8b877f; } +.zb-pcb { fill: #a48f55; } +.zb-pad { fill: #d3bd73; } +.zb-base { fill: #121212; } +.zb-base-sheen { fill: #2b2b2b; } +.zb-glow { fill: #ff9a2e; opacity: .85; filter: blur(4px); } +.zb-shield { fill: #cfcfca; stroke: #8f8f8a; stroke-width: 3; } +.zb-hole { fill: #0b0b0b; } +.zb-hole-key { fill: #0b0b0b; } +.zb-contact { fill: #b89545; } +.zb-led { stroke: #5c5c58; stroke-width: 1.5; } +.zb-usb-tongue { fill: #2f7fe0; } +.zb-usb-pin { fill: #c7a64b; } +.zb-dp-pins { fill: #b89545; } +.zb-dc { fill: #3a3a3a; stroke: #222; stroke-width: 2; } +.zb-dc-face { fill: #2b2b2b; } +.zb-dc-ring { fill: #4b4b4b; } +.zb-dc-pin { fill: #c9a43a; } diff --git a/templates/cutsheet.html b/templates/cutsheet.html index 60884f9..5894592 100644 --- a/templates/cutsheet.html +++ b/templates/cutsheet.html @@ -275,11 +275,18 @@
- + +
+
+ +
@@ -288,6 +295,10 @@ +
+ + +
Gear on a shelf (mini PCs, desk switches) takes its place and side from the shelf: leave its rack unit empty. On a 4-post rack a front and a rear item can share a unit; a full-depth one (a server) takes it on both sides.
diff --git a/templates/rack.html b/templates/rack.html index f1b465c..63f2739 100644 --- a/templates/rack.html +++ b/templates/rack.html @@ -21,8 +21,12 @@
- - + + +
diff --git a/tests/test_app.py b/tests/test_app.py index 9090c44..9fb9186 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -524,17 +524,35 @@ class TestRackFacesAndSettings: gandalf.db.cutsheet_load.return_value = { 'items': [dict(base, id=n, **i) for n, i in enumerate(items, 1)], 'cables': [], 'port_labels': []} - def test_rear_face(self, client): + def test_three_views(self, client): + # A server through both posts, a PDU on the rear posts facing the front (as in the StarTech), + # a fan tray on the rear posts facing the rear, and a switch on the front posts self._items({'name': 'Server', 'kind': 'device', 'rack_u': 5, 'rack_height': 4, 'mount': 'full'}, {'name': 'PDU', 'kind': 'device', 'rack_u': 12, 'rack_height': 1, 'mount': 'rear'}, + {'name': 'Fans', 'kind': 'device', 'rack_u': 14, 'rack_height': 1, 'mount': 'rear', + 'facing': 'rear'}, {'name': 'Switch', 'kind': 'device', 'rack_u': 12, 'rack_height': 1}) - front = client.get('/api/rack', headers=AUTH).get_json() - rear = client.get('/api/rack?face=rear', headers=AUTH).get_json() - assert [(i['name'], i['through']) for i in front['items']] == [('Switch', False), ('Server', False)] - assert [(i['name'], i['through']) for i in rear['items']] == [('PDU', False), ('Server', True)] - assert rear['face'] == 'rear' and rear['racks'][0]['rear'] == 2 and rear['racks'][0]['count'] == 3 + + def view(face): + return [(i['name'], i['through']) for i in + client.get(f'/api/rack?face={face}', headers=AUTH).get_json()['items']] + assert view('front') == [('Switch', False), ('Server', False)] + assert view('behind') == [('Fans', True), ('PDU', False), ('Server', False)] + assert view('rear') == [('Fans', False), ('PDU', True), ('Server', True)] + racks = client.get('/api/rack', headers=AUTH).get_json()['racks'][0] + assert (racks['count'], racks['rear'], racks['behind']) == (4, 3, 2) assert client.get('/api/rack?face=side', headers=AUTH).status_code == 400 + def test_shelf_contents_ordered_with_nics(self, client): + self._items({'name': 'Shelf', 'kind': 'shelf', 'rack_u': 3, 'rack_height': 2, 'mount': 'rear'}, + {'name': 'b-box', 'kind': 'device', 'shelf': 'Shelf', 'slot': 1, 'chassis': 'zimaboard-832'}, + {'name': 'large1', 'kind': 'device', 'shelf': 'Shelf', 'slot': 2}) + data = client.get('/api/rack?face=behind', headers=AUTH).get_json() + things = data['items'][0]['contents'] + 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}] + def test_saved_racks_replace_config(self, client): self._items() gandalf.db.cutsheet_racks.return_value = [{'id': 4, 'name': 'StarTech 25U', 'units': 25, 'notes': 'open frame'}] diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index 0023c38..41f60cc 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -369,3 +369,23 @@ 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'} <= set(cs.CHASSIS) + + +class TestFacingAndSlots: + def test_facing_and_slot_validation(self): + shelf = {'S': {'name': 'S', 'kind': 'shelf'}} + assert cs.validate_item({'name': 'p', 'facing': 'rear'})['facing'] == 'rear' + assert cs.validate_item({'name': 'p'})['facing'] == 'front' + assert cs.validate_item({'name': 'z', 'shelf': 'S', 'slot': '2'}, shelf)['slot'] == 2 + with pytest.raises(cs.ValidationError): + cs.validate_item({'name': 'p', 'facing': 'up'}) + with pytest.raises(cs.ValidationError): + cs.validate_item({'name': 'p', 'slot': 1}) # a position needs a shelf + + def test_rear_posts_facing_front_are_seen_from_the_front(self): + items = [{'id': 1, 'name': 'pdu', 'kind': 'device', 'rack_u': 24, 'rack_height': 2, 'mount': 'rear'}, + {'id': 2, 'name': 'udm', 'kind': 'device', 'rack_u': 24, 'rack_height': 1}] + assert [i['name'] for i in cs.rack_layout(items, 25, 'front')['placed']] == ['udm'] + behind = cs.rack_layout(items, 25, 'behind')['placed'] + assert [(i['name'], i['through']) for i in behind] == [('pdu', False)] + assert [(i['name'], i['through']) for i in cs.rack_layout(items, 25, 'rear')['placed']] == [('pdu', True)]