diff --git a/README.md b/README.md index 4d9ee87..c5e714a 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,20 @@ 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: the onboard RJ45 link lights follow + 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. - **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: @@ -734,7 +744,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 e657633..3f13b1d 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 = { @@ -34,6 +39,8 @@ CHASSIS = { 'usw-pro-max-48-poe': 'UniFi Switch Pro Max 48 PoE (until UniFi reports it)', 'sliger-cx4712': 'Sliger CX4712 4U (10-bay)', '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)', 'server-1u': 'Generic 1U server', 'server-2u': 'Generic 2U server', 'server-4u': 'Generic 4U server', @@ -112,11 +119,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']: @@ -129,6 +142,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']: @@ -604,15 +618,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')] @@ -632,8 +651,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 b4aaa4d..c068120 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. // @@ -151,3 +151,147 @@ 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('')}`; +} + +// Which NIC is which on a ZimaBoard: the onboard Realtek GbE ports enumerate on PCI buses 2 and 3 +// (enp2s0 left jack, enp3s0 right), and a card in the PCIe slot on bus 1 (enp1s0, enp1s0d1). +const ZIMA_NICS = { rj45: ['enp2s0', 'enp3s0'], slot: /^enp1s0/ }; + +function zimaNic(ctx, name) { + const n = (ctx.interfaces || []).find(i => i.name === name); + return n ? n.up : ctx.status === 'up'; +} + +function zimaFront(ctx) { + // ctx: { status, interfaces: [{name, up}] } + const link = i => zimaNic(ctx, ZIMA_NICS.rj45[i]); + 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)} + + + + + `; +} + +// A Mellanox ConnectX-3 single-port SFP+ card (MCX311A) in the ZimaBoard's side PCIe slot, lying flat +// beside the board as in the rack photo (wiki.lotusguild.org/zimaboards.jpg): its front edge is about +// 54 mm (572 px at the ZimaBoard photo's 10.59 px/mm), PCB at the board's PCB height, heatsink on the +// chip, and the SFP+ cage toward the board with a DAC plugged in and its pull tab hanging down. +// The round dot on the cage is Gandalf's link mark (like the cutsheet marks), not an LED on the card. +const CX3_PX = 572; + +function cx3Card(up) { + return ` + + + + + + + ${Array.from({ length: 9 }, (_, i) => ``).join('')} + + + + + + + Gandalf: SFP+ link ${up ? 'up' : 'down'}`; +} + +function zimaCx3Front(ctx) { + return `${cx3Card(zimaNic(ctx, (ctx.interfaces || []).map(i => i.name).find(n => ZIMA_NICS.slot.test(n)) || 'enp1s0'))} + ${zimaFront(ctx)}`; +} + +const ZIMA_SPECS = 'Intel Celeron N3450, 8GB LPDDR4, 32GB eMMC, 2x GbE, 2x USB 3.0, 2x SATA, PCIe 2.0 x4, 6W'; + +const SHELF_GEAR = { + 'zimaboard-832': { + label: 'IceWhale ZimaBoard 832', mm: ZIMA.mm, width: ZIMA.width, height: ZIMA.height, draw: zimaFront, + specs: ZIMA_SPECS, + }, + 'zimaboard-832-cx3': { + label: 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+', mm: ZIMA.mm * (ZIMA.width + CX3_PX) / ZIMA.width, + width: ZIMA.width + CX3_PX, height: ZIMA.height, draw: zimaCx3Front, + specs: `${ZIMA_SPECS}; Mellanox ConnectX-3 10G SFP+ (enp1s0) in the PCIe slot, DAC attached`, + }, +}; + +// 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 59a1aa5..0313b95 100644 --- a/static/style.css +++ b/static/style.css @@ -1585,4 +1585,48 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .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; } +.cx-pcb { fill: #1d6b43; } +.cx-pcb-edge { fill: #2f8a5a; } +.cx-part { fill: #26292d; } +.cx-heatsink { fill: #18191b; } +.cx-fin { fill: #2c2e31; } +.cx-cage { fill: #c8cbcf; stroke: #85898f; stroke-width: 3; } +.cx-cage-lip { fill: #dde0e3; } +.cx-plug { fill: #2b2e33; } +.cx-boot { fill: #1b1d20; } +.cx-cable { fill: #0e0f10; } +.cx-tab { fill: none; stroke: #c9ccd0; stroke-width: 9; stroke-linejoin: round; } +.cx-link { stroke: #101418; stroke-width: 3; } .fp-speedbar { fill: #b3b5ba; } 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 ee0e3cc..48e02f5 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -369,4 +369,24 @@ 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'} <= set(cs.CHASSIS) + 'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3'} <= 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)]