From c50e1f8322f40081506421af53243a374bbbd6e3 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Nov 2026 22:35:37 -0500 Subject: [PATCH] Rack: front and rear mounting, rack settings in the page Items are mounted from the front, the rear, or full depth. On a 4-post rack a front and a rear item can share a unit; a full-depth one takes both sides, and the rear view shows its back. Overlaps are checked per side. Shelf contents take the shelf's side. A Front / Rear switch flips the drawing. Racks are saved in Gandalf (new cutsheet_racks table) and edited on the rack page by admins: name, height, notes, add and delete. Renaming moves the rack's items; saving the first rack carries over the items of the config default it replaces. Config racks remain the fallback. Faceplates are stretched to the full width only when the drawing is close to the slot's proportions, so the PDU is not squashed. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++-- app.py | 71 ++++++++++++++++++----- cutsheet.py | 53 ++++++++++++++---- db.py | 74 +++++++++++++++++++++++- static/cutsheet.js | 12 ++-- static/rack.js | 121 +++++++++++++++++++++++++++++++++++----- static/style.css | 8 +++ templates/cutsheet.html | 12 +++- templates/rack.html | 47 +++++++++++++++- tests/test_app.py | 55 +++++++++++++++++- tests/test_cutsheet.py | 36 ++++++++++++ 11 files changed, 453 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 9e9f488..ab75903 100644 --- a/README.md +++ b/README.md @@ -135,10 +135,15 @@ The rack front, drawn from the rack units set on cutsheet items, top first. item and its device as the same thing. - **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. -- **More than one rack**: each item has a rack (empty means the first). The page shows a tab per rack. - Configure them with `racks: [{"name": "Main", "units": 42}, ...]`; without it there is one rack, - `rack.name` (default "Rack 1") with `rack.units` (default 42) units. A rack name used by an item but not - configured gets the default height. +- **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`). +- **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: + `racks: [{"name": "Main", "units": 42}, ...]`, or one rack `rack.name` (default "Rack 1") with + `rack.units` (default 42); saving the first one carries its items over. - Every documented port shows its cutsheet state (green matches, red mismatch, grey unconfirmed). Hovering a port lights up every port on the same cable run, through patch panels, and names the cables; clicking opens the switch port or the cable. @@ -712,7 +717,8 @@ 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` and `device` columns; they are added automatically on first use +- `cutsheet_items` gained `rack`, `shelf`, `device` and `mount` 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 `recovery_notified` once the recovery note is on its ticket (column added automatically at monitor startup) diff --git a/app.py b/app.py index 8f88c74..d03aa3b 100644 --- a/app.py +++ b/app.py @@ -1073,11 +1073,11 @@ def rack_page(): def _racks(items: list) -> list: - """Every rack, in order: the configured ones, then any other name an item uses. + """Every rack, in order: those saved on the rack page, then any other name an item uses. - Config: "racks": [{"name": "Main", "units": 42}, ...]. Without it there is one rack, - named rack.name (default "Rack 1") with rack.units (default 42). An item with no rack - belongs to the first. + With none saved, racks come from config: "racks": [{"name": "Main", "units": 42}, ...], or + one rack named rack.name (default "Rack 1") with rack.units (default 42). Those have no id + until saved. An item with no rack belongs to the first. """ def units(value, default=42): try: @@ -1088,15 +1088,19 @@ def _racks(items: list) -> list: cfg = _config() single = cfg.get('rack') or {} default_units = units(single.get('units', 42)) - racks = [{'name': str(r.get('name') or '').strip(), 'units': units(r.get('units', default_units), default_units)} - for r in cfg.get('racks') or [] if isinstance(r, dict) and str(r.get('name') or '').strip()] + racks = [{'id': r['id'], 'name': r['name'], 'units': r['units'], 'notes': r.get('notes') or ''} + for r in db.cutsheet_racks()] if not racks: - racks = [{'name': str(single.get('name') or 'Rack 1'), 'units': default_units}] + racks = [{'id': None, 'name': str(r.get('name') or '').strip(), 'notes': '', + 'units': units(r.get('units', default_units), default_units)} + for r in cfg.get('racks') or [] if isinstance(r, dict) and str(r.get('name') or '').strip()] + if not racks: + racks = [{'id': None, 'name': str(single.get('name') or 'Rack 1'), 'units': default_units, 'notes': ''}] named = {r['name'] for r in racks} for item in items: if item.get('rack') and item['rack'] not in named: named.add(item['rack']) - racks.append({'name': item['rack'], 'units': default_units}) + racks.append({'id': None, 'name': item['rack'], 'units': default_units, 'notes': ''}) return racks @@ -1113,10 +1117,13 @@ def _device_status(name: str, snapshot: dict) -> str: @app.route('/api/rack') @require_auth def api_rack(): - """One rack's front: placed cutsheet items, live switch ports, and each port's cutsheet state. + """One rack face: placed cutsheet items, live switch ports, and each port's cutsheet state. - Query: rack (name; default the first 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 view = _cutsheet_view() link_stats = _state_json('link_stats') switches = link_stats.get('unifi_switches') or {} @@ -1135,7 +1142,9 @@ def api_rack(): return item.get('rack') or default for r in racks: - r['count'] = sum(1 for i in view['items'] if not i.get('shelf') and i.get('rack_u') and rack_of(i) == r['name']) + 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) + r['rear'] = sum(1 for i in racked if i.get('mount') in ('rear', 'full')) 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']) @@ -1156,7 +1165,7 @@ def api_rack(): on_shelf.setdefault(item['shelf'], []).append(item) here = [i for i in view['items'] if not i.get('shelf') and rack_of(i) == current['name']] - layout = cutsheet.rack_layout(here, current['units']) + layout = cutsheet.rack_layout(here, current['units'], face) out = [] for item in layout['placed']: name, device = item['name'], dev(item) @@ -1164,6 +1173,8 @@ def api_rack(): 'rack_top', 'location', 'notes')} entry['device'] = device entry['linked'] = device in known + entry['mount'] = item.get('mount') or 'front' + entry['through'] = item.get('through', False) entry['url'] = url(item) entry['status'] = _device_status(device, snapshot) if device in known else '' # Cables may name either the item or the device it is linked to @@ -1185,13 +1196,47 @@ def api_rack(): ] out.append(entry) return jsonify({ - 'racks': racks, 'rack': current['name'], 'units': current['units'], 'items': out, + 'racks': racks, 'rack': current['name'], 'rack_id': current['id'], 'units': current['units'], + 'notes': current['notes'], 'face': face, 'items': out, 'overlaps': layout['overlaps'], 'outside': layout['outside'], 'unplaced': sorted(i['name'] for i in here if not i.get('rack_u')), 'updated': link_stats.get('updated'), }) +@app.route('/api/cutsheet/racks', methods=['POST']) +@require_auth +@require_admin +@_cutsheet_write +def api_cutsheet_create_rack(): + """Add a rack. `previous` is the name of the default rack this replaces, when the first is saved.""" + body = _json_body() + rack = cutsheet.validate_rack(body) + previous = str(body.get('previous') or '')[:100] if not db.cutsheet_racks() else '' + rack_id = db.cutsheet_save_rack(None, rack, _get_user()['username'], previous=previous) + return jsonify({'success': True, 'id': rack_id}) + + +@app.route('/api/cutsheet/racks/', methods=['PUT']) +@require_auth +@require_admin +@_cutsheet_write +def api_cutsheet_update_rack(rack_id: int): + rack = cutsheet.validate_rack(_json_body()) + db.cutsheet_save_rack(rack_id, rack, _get_user()['username']) + return jsonify({'success': True}) + + +@app.route('/api/cutsheet/racks/', methods=['DELETE']) +@require_auth +@require_admin +@_cutsheet_write +def api_cutsheet_delete_rack(rack_id: int): + if not db.cutsheet_delete_rack(rack_id, _get_user()['username']): + return jsonify({'error': 'No such rack'}), 404 + return jsonify({'success': True}) + + @app.route('/api/cutsheet/cables', methods=['POST']) @require_auth @require_admin diff --git a/cutsheet.py b/cutsheet.py index cd82cd8..b3614a2 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -22,6 +22,10 @@ import re from typing import Dict, List, Optional, Tuple 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') +RACK_UNITS_MAX = 60 PASSIVE_KINDS = ('patch_panel', 'wall_jack') SIDES = ('front', 'rear') STATUSES = ('planned', 'installed', 'dark', 'retired') @@ -94,9 +98,12 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict: 'rack_height': _int(data, 'rack_height', 1, 20, 'Height'), 'rack': _text(data, 'rack', RACK_NAME_MAX), 'device': _text(data, 'device'), + 'mount': _text(data, 'mount', 10) or 'front', 'shelf': _text(data, 'shelf', NAME_MAX), 'notes': _text(data, 'notes', NOTES_MAX), } + if item['mount'] not in MOUNTS: + raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}') if item['shelf']: shelf = (items or {}).get(item['shelf']) if not shelf or shelf['kind'] != 'shelf': @@ -105,7 +112,8 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict: raise ValidationError('A shelf cannot sit on a shelf') if item['rack_u'] or item['rack_height']: raise ValidationError('An item on a shelf takes its rack position from the shelf; clear the rack unit') - item['rack'] = '' # the shelf's rack + item['rack'] = '' # the shelf's rack and side + item['mount'] = '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']: @@ -575,14 +583,22 @@ def problems(cables: List[dict], items: Dict[str, dict], switches: dict) -> List # ── Rack elevation ────────────────────────────────────────────────────────── -def rack_layout(items: List[dict], units: int) -> dict: - """Place items that have a rack unit, top of the rack first. +def _sides(item: dict) -> Tuple[str, ...]: + mount = item.get('mount') or 'front' + return ('front', 'rear') if mount == 'full' else (mount,) - An item that would share a unit with one already placed, or runs past the - top of the rack, is reported rather than drawn over another. - Returns {'placed': [item + 'rack_top'], 'overlaps': [{'name', 'units', 'with'}], 'outside': [name]}. + +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. + + 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. + 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). """ - taken: Dict[int, str] = {} + taken: Dict[tuple, str] = {} placed, overlaps, outside = [], [], [] racked = [i for i in items if i.get('rack_u')] # Top first; where two claim the same top unit, the one recorded first is drawn @@ -593,13 +609,16 @@ def rack_layout(items: List[dict], units: int) -> dict: if high > units: outside.append({'name': item['name'], 'units': span}) continue - clash = sorted({taken[u] for u in range(low, high + 1) if u in taken}) + sides = _sides(item) + cells = [(u, s) for u in range(low, high + 1) for s in sides] + clash = sorted({taken[c] for c in cells if c in taken}) if clash: overlaps.append({'name': item['name'], 'units': span, 'with': clash}) continue - for u in range(low, high + 1): - taken[u] = item['name'] - placed.append(dict(item, rack_top=high)) + 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')) return {'placed': placed, 'overlaps': overlaps, 'outside': outside} @@ -633,3 +652,15 @@ def port_marks(cables: List[dict]) -> Dict[tuple, dict]: entry['text'] = '; '.join(f'cable{"s" if len(labels) > 1 else ""} {", ".join(labels)}: {detail}' for detail, labels in entry.pop('by_detail').items()) return out + + +def validate_rack(data: dict) -> dict: + """A rack from the rack settings form.""" + rack = { + 'name': _text(data, 'name', RACK_NAME_MAX, required=True, label='Rack name'), + 'units': _int(data, 'units', 1, RACK_UNITS_MAX, 'Height'), + 'notes': _text(data, 'notes', NOTES_MAX), + } + if not rack['units']: + raise ValidationError('Set the rack height in units') + return rack diff --git a/db.py b/db.py index 375b5a2..666697c 100644 --- a/db.py +++ b/db.py @@ -677,6 +677,7 @@ _CUTSHEET_SCHEMA = ( rack VARCHAR(100) NOT NULL DEFAULT '', shelf VARCHAR(100) NOT NULL DEFAULT '', device VARCHAR(255) NOT NULL DEFAULT '', + mount VARCHAR(10) NOT NULL DEFAULT 'front', notes TEXT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -725,6 +726,15 @@ _CUTSHEET_SCHEMA = ( INDEX idx_hist_time (changed_at), INDEX idx_hist_entity (entity, entity_id) ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS cutsheet_racks ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + units SMALLINT NOT NULL, + notes TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_rack_name (name) + ) ENGINE=InnoDB""", # Where a device lives in the asset inventory (HomeBox), set by hand on its Gandalf page """CREATE TABLE IF NOT EXISTS device_links ( name VARCHAR(255) NOT NULL PRIMARY KEY, @@ -758,13 +768,14 @@ 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', - 'notes') + 'mount', 'notes') # Columns added to cutsheet_items after its first release: (name, definition) _ITEM_COLUMNS_ADDED = ( ('rack', "VARCHAR(100) NOT NULL DEFAULT ''"), ('shelf', "VARCHAR(100) NOT NULL DEFAULT ''"), # The Gandalf device (UniFi or host name) an item is, when its own name differs ('device', "VARCHAR(255) NOT NULL DEFAULT ''"), + ('mount', "VARCHAR(10) NOT NULL DEFAULT 'front'"), ) _CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side', 'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes') @@ -1027,3 +1038,64 @@ def cutsheet_import_cables(cables: list, user: str) -> dict: None, cable) added += 1 return {'added': added, 'updated': updated} + + +# --------------------------------------------------------------------------- +# Racks (cutsheet_racks): name and height, edited on the rack page +# --------------------------------------------------------------------------- + +def cutsheet_racks() -> list: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute('SELECT id, name, units, notes FROM cutsheet_racks ORDER BY id') + return list(cur.fetchall()) + + +def cutsheet_save_rack(rack_id: Optional[int], rack: dict, user: str, previous: str = '') -> int: + """Create (rack_id None) or update a rack. A rename carries over to the items in it. + + `previous` names the rack being replaced when the first rack is saved for the first + time (it existed only as the default until then), so its items follow the new name. + """ + with _cs_transaction() as cur: + before = None + if rack_id is not None: + cur.execute('SELECT * FROM cutsheet_racks WHERE id=%s', (rack_id,)) + before = cur.fetchone() + if before is None: + raise CutsheetConflict('No such rack') + cur.execute('SELECT name FROM cutsheet_racks WHERE LOWER(name)=LOWER(%s) AND id<>%s', + (rack['name'], rack_id or 0)) + clash = cur.fetchone() + if clash: + raise CutsheetConflict(f'A rack named {clash["name"]} already exists') + if before is None: + cur.execute('INSERT INTO cutsheet_racks (name, units, notes) VALUES (%s, %s, %s)', + (rack['name'], rack['units'], rack['notes'])) + rack_id = cur.lastrowid + old_name = previous + else: + cur.execute('UPDATE cutsheet_racks SET name=%s, units=%s, notes=%s WHERE id=%s', + (rack['name'], rack['units'], rack['notes'], rack_id)) + old_name = before['name'] + if old_name and old_name != rack['name']: + cur.execute('UPDATE cutsheet_items SET rack=%s WHERE rack=%s', (rack['name'], old_name)) + renamed = f' (was {old_name})' if old_name and old_name != rack['name'] else '' + _cs_log(cur, user, 'rack', rack_id, 'update' if before else 'create', + f'Rack {rack["name"]}: {rack["units"]}U{renamed}', _cs_jsonable(before), rack) + return rack_id + + +def cutsheet_delete_rack(rack_id: int, user: str) -> bool: + with _cs_transaction() as cur: + cur.execute('SELECT * FROM cutsheet_racks WHERE id=%s', (rack_id,)) + before = cur.fetchone() + if before is None: + return False + cur.execute('SELECT COUNT(*) AS n FROM cutsheet_items WHERE rack=%s', (before['name'],)) + n = cur.fetchone()['n'] + if n: + raise CutsheetConflict(f'{before["name"]} still holds {n} item(s); move them to another rack first') + cur.execute('DELETE FROM cutsheet_racks WHERE id=%s', (rack_id,)) + _cs_log(cur, user, 'rack', rack_id, 'delete', f'Removed rack {before["name"]}', _cs_jsonable(before), None) + return True diff --git a/static/cutsheet.js b/static/cutsheet.js index 5d6a032..dac4bac 100644 --- a/static/cutsheet.js +++ b/static/cutsheet.js @@ -260,7 +260,8 @@ function renderRack() { function rackText(i) { if (i.shelf) return `on ${esc(i.shelf)}`; if (!i.rack_u) return '–'; - const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}`; + const side = { rear: ' rear', full: ' full depth' }[i.mount] || ''; + 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; } @@ -388,13 +389,14 @@ function openCable(cable, prefill) { function openItem(item) { const form = document.getElementById('cs-item-form'); form.reset(); - const data = Object.assign({ kind: 'patch_panel' }, item || {}); + const data = Object.assign({ kind: 'patch_panel', mount: 'front' }, item || {}); document.getElementById('cs-rack-list').innerHTML = (_cs.racks || []).map(r => `' + shelves.map(i => ``).join(''); - for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'shelf', 'location', 'notes']) { + for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf', + 'location', 'notes']) { form[key].value = data[key] == null ? '' : data[key]; } if (!item) form.port_count.value = 24; @@ -447,8 +449,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', 'shelf', 'location', - 'notes']); + const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf', + '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/rack.js b/static/rack.js index 5dd961e..a0d5c31 100644 --- a/static/rack.js +++ b/static/rack.js @@ -1,5 +1,5 @@ 'use strict'; -/* global FACEPLATES, renderFaceplate */ +/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT */ // 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. @@ -114,9 +114,18 @@ function plateHtml(item) { `; } +function backHtml(item) { + // A full-depth item seen from the rear: its back, not its faceplate + return `
+ ${statusDot(item.status)}${esc(item.name)} + back of a full-depth ${esc(KIND_TEXT[item.kind] || item.kind).toLowerCase()} +
`; +} + function itemHtml(item) { let body; - if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item); + if (item.through) body = backHtml(item); + else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item); else if (item.kind === 'patch_panel') body = panelSvg(item); else if (item.kind === 'shelf') body = shelfHtml(item); else body = plateHtml(item); @@ -161,7 +170,14 @@ function renderTabs(data) { return ``; }).join(''); - document.getElementById('rack-title').textContent = data.racks.length > 1 ? `${data.rack} · front` : 'Front'; + document.getElementById('rack-title').textContent = `${data.rack} · ${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'; } function render(data) { @@ -183,8 +199,17 @@ function render(data) { u = low - 1; } document.getElementById('rack').innerHTML = rows.join(''); - // Every rack device is 19" wide: fill the slot rather than keep each drawing's own proportions - document.querySelectorAll('#rack .fp-svg').forEach(s => s.setAttribute('preserveAspectRatio', 'none')); + // Every rack device is 19" wide: stretch a faceplate to fill its slot when its drawing is close to + // the slot's proportions. A drawing far from them (the PDU's is twice as tall as a real 1U) keeps its + // shape rather than being squashed. + document.querySelectorAll('#rack .rack-slot').forEach(slot => { + const svg = slot.querySelector('.fp-svg'); + if (!svg) return; + const [, , w, h] = svg.getAttribute('viewBox').split(' ').map(Number); + const n = parseInt(slot.closest('.rack-row').style.getPropertyValue('--n'), 10); + const off = (w / h) / (RATIO / n); + svg.setAttribute('preserveAspectRatio', off > 0.85 && off < 1.15 ? 'none' : 'xMidYMid meet'); + }); document.getElementById('rack-show-empty').checked = showEmpty(); document.getElementById('rack-count').textContent = data.items.length; @@ -283,19 +308,37 @@ function currentRack() { return new URLSearchParams(location.search).get('rack') || ''; } +function currentFace() { + return new URLSearchParams(location.search).get('face') === 'rear' ? 'rear' : 'front'; +} + +function setView(changes) { + const q = new URLSearchParams(location.search); + Object.entries(changes).forEach(([k, v]) => (v ? q.set(k, v) : q.delete(k))); + const qs = q.toString(); + history.replaceState(null, '', qs ? `?${qs}` : location.pathname); + _rack = null; + load(); +} + document.getElementById('rack-tabs').addEventListener('click', e => { const tab = e.target.closest('[data-rack]'); if (!tab) return; const first = _rack && _rack.racks[0] && _rack.racks[0].name; - history.replaceState(null, '', tab.dataset.rack === first ? location.pathname : `?rack=${encodeURIComponent(tab.dataset.rack)}`); - _rack = null; - load(); + setView({ rack: tab.dataset.rack === first ? '' : tab.dataset.rack }); +}); + +document.querySelector('.rack-face').addEventListener('click', e => { + const btn = e.target.closest('[data-face]'); + if (btn) setView({ face: btn.dataset.face === 'rear' ? 'rear' : '' }); }); async function load() { try { - const rack = currentRack(); - render(await lt.api.get(`/api/rack${rack ? `?rack=${encodeURIComponent(rack)}` : ''}`)); + const q = new URLSearchParams(); + if (currentRack()) q.set('rack', currentRack()); + if (currentFace() === 'rear') q.set('face', 'rear'); + render(await lt.api.get(`/api/rack${q.toString() ? `?${q}` : ''}`)); } catch (err) { if (_rack) return; // keep the last drawing on a failed refresh document.getElementById('rack').innerHTML = `
@@ -342,7 +385,7 @@ function buildExport() { root.appendChild(svgEl('rect', { width: '100%', height: '100%', fill: '#ffffff' })); const stamp = new Date().toLocaleString(); root.appendChild(svgEl('text', { x: pad, y: pad + 14, 'font-size': 18, 'font-weight': 700, fill: '#111' }, - `${_rack.rack} · front`)); + `${_rack.rack} · ${_rack.face}`)); root.appendChild(svgEl('text', { x: W + gutter + pad, y: pad + 14, 'font-size': 11, fill: '#666', 'text-anchor': 'end' }, `Gandalf · ${stamp}`)); @@ -370,7 +413,7 @@ function buildExport() { const clone = art.cloneNode(true); inlineStyles(art, clone); ['x', 'y', 'width', 'height'].forEach((k, i) => clone.setAttribute(k, [x, y, W, h][i])); - clone.setAttribute('preserveAspectRatio', art.classList.contains('fp-svg') ? 'none' : 'xMidYMid meet'); + clone.setAttribute('preserveAspectRatio', art.getAttribute('preserveAspectRatio') || 'xMidYMid meet'); clone.removeAttribute('style'); root.appendChild(clone); } else if (plate) { @@ -406,7 +449,7 @@ async function exportRack(kind) { return; } const svg = buildExport(); - const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-rack`; + const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-${_rack.face}`; if (kind === 'svg') { download(new Blob([svg], { type: 'image/svg+xml' }), `${base}.svg`); return; @@ -436,3 +479,55 @@ document.getElementById('rack-show-empty').addEventListener('change', e => { }); document.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', () => exportRack(btn.dataset.export))); + +// ── Rack settings (admins) ────────────────────────────────────────────── +function openRackForm(adding) { + const form = document.getElementById('rack-form'); + form.reset(); + const r = adding ? null : (_rack.racks.find(x => x.name === _rack.rack) || {}); + form.id.value = r && r.id ? r.id : ''; + // The default rack exists only in config until saved; its items follow the new name + form.previous.value = r && !r.id ? r.name : ''; + form.name.value = r ? r.name : ''; + form.units.value = r ? r.units : 42; + form.notes.value = r ? (r.notes || '') : ''; + form.querySelector('[data-action="delete-rack"]').hidden = !(r && r.id); + document.getElementById('rack-modal-title').textContent = adding ? 'Add a rack' : 'Rack settings'; + lt.modal.open('rack-modal'); + form.name.focus(); +} + +async function saveRack(e) { + e.preventDefault(); + const form = e.target; + const body = { name: form.name.value.trim(), units: form.units.value, notes: form.notes.value, previous: form.previous.value }; + try { + if (form.id.value) await lt.api.put(`/api/cutsheet/racks/${encodeURIComponent(form.id.value)}`, body); + else await lt.api.post('/api/cutsheet/racks', body); + lt.modal.close('rack-modal'); + lt.toast.success('Rack saved'); + setView({ rack: body.name }); + } catch (err) { + lt.toast.error(err.message || 'Save failed'); + } +} + +async function deleteRack() { + const form = document.getElementById('rack-form'); + if (!form.id.value || !window.confirm(`Delete the rack ${form.name.value}?`)) return; + try { + await lt.api.delete(`/api/cutsheet/racks/${encodeURIComponent(form.id.value)}`); + lt.modal.close('rack-modal'); + lt.toast.success('Rack deleted'); + setView({ rack: '' }); + } catch (err) { + lt.toast.error(err.message || 'Delete failed'); + } +} + +if (RACK_CAN_EDIT) { + document.querySelector('[data-action="rack-settings"]').addEventListener('click', () => _rack && openRackForm(false)); + document.querySelector('[data-action="add-rack"]').addEventListener('click', () => _rack && openRackForm(true)); + document.getElementById('rack-form').addEventListener('submit', saveRack); + document.querySelector('[data-action="delete-rack"]').addEventListener('click', deleteRack); +} diff --git a/static/style.css b/static/style.css index 09e9efe..7507aee 100644 --- a/static/style.css +++ b/static/style.css @@ -1515,3 +1515,11 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .rack-shelf-thing:hover, .rack-shelf-thing:focus-visible { outline: 2px solid var(--accent-cyan); } .rack-shelf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @container (max-height: 50px) { .rack-shelf-label { display: none; } .rack-shelf-thing { font-size: .6rem; gap: 3px; } } +.rack-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; + max-width: 1000px; margin: 0 auto 14px; } +.rack-bar .rack-tabs { margin: 0 !important; } +.rack-face { display: inline-flex; border: 1px solid var(--border-color); border-radius: 3px; overflow: hidden; margin-left: auto; } +.rack-face-btn { background: none; border: 0; color: inherit; font: inherit; font-size: .82rem; padding: 6px 14px; cursor: pointer; } +.rack-face-btn + .rack-face-btn { border-left: 1px solid var(--border-color); } +.rack-face-btn.active { background: var(--accent-cyan-dim); color: var(--accent-cyan); } +.rack-back { opacity: .75; background: repeating-linear-gradient(135deg, var(--rk-plate-b) 0 8px, var(--rk-frame) 8px 16px); } diff --git a/templates/cutsheet.html b/templates/cutsheet.html index 12a30d1..fa3dc06 100644 --- a/templates/cutsheet.html +++ b/templates/cutsheet.html @@ -269,12 +269,22 @@
+
+ + +
+ +
-
Gear on a shelf (mini PCs, desk switches) takes its place from the shelf: leave its rack unit empty.
+
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 8cfdc88..fdfb399 100644 --- a/templates/rack.html +++ b/templates/rack.html @@ -11,12 +11,20 @@ {% if can_edit %} + + Edit positions {% endif %}
- +
+ +
+ + +
+
@@ -35,9 +43,46 @@

+{% if can_edit %} + +{% endif %} {% endblock %} {% block scripts %} + {% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index c6be463..53b0b63 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -36,6 +36,7 @@ def client(): db.get_status_summary.return_value = {'critical': 0, 'warning': 0, 'info': 0, 'resolved_24h': 0} db.check_suppressed.return_value = False db.get_device_link.return_value = '' + db.cutsheet_racks.return_value = [] with patch.object(gandalf, '_cfg', CONFIG): yield gandalf.app.test_client() @@ -503,9 +504,59 @@ class TestRackRacksShelvesLinks: cfg = dict(CONFIG, racks=[{'name': 'Main', 'units': 24}]) with patch.object(gandalf, '_cfg', cfg): data = client.get('/api/rack', headers=AUTH).get_json() - assert data['racks'] == [{'name': 'Main', 'units': 24, 'count': 1}, - {'name': 'Garage', 'units': 42, 'count': 1}] + assert [(r['id'], r['name'], r['units'], r['count']) for r in data['racks']] == [ + (None, 'Main', 24, 1), (None, 'Garage', 42, 1)] assert data['rack'] == 'Main' and [i['name'] for i in data['items']] == ['A'] garage = client.get('/api/rack?rack=Garage', headers=AUTH).get_json() assert [i['name'] for i in garage['items']] == ['B'] and garage['units'] == 42 assert client.get('/api/rack?rack=Nope', headers=AUTH).status_code == 404 + + +class TestRackFacesAndSettings: + def _items(self, *items): + base = {'port_count': None, 'rack_u': None, 'rack_height': None, 'rack': '', 'shelf': '', 'device': '', + 'mount': 'front', 'location': '', 'notes': ''} + gandalf._cutsheet_schema_ready = True + 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): + 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': '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 + assert client.get('/api/rack?face=side', headers=AUTH).status_code == 400 + + 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'}] + data = client.get('/api/rack', headers=AUTH).get_json() + assert (data['rack'], data['rack_id'], data['units'], data['notes']) == ('StarTech 25U', 4, 25, 'open frame') + + def test_first_save_carries_the_default_racks_items(self, client): + gandalf._cutsheet_schema_ready = True + gandalf.db.cutsheet_save_rack.return_value = 7 + resp = client.post('/api/cutsheet/racks', json={'name': 'StarTech 25U', 'units': 25, 'previous': 'Rack 1'}, + headers=AUTH) + assert resp.get_json() == {'success': True, 'id': 7} + args, kwargs = gandalf.db.cutsheet_save_rack.call_args + assert args[0] is None and args[1]['units'] == 25 and kwargs == {'previous': 'Rack 1'} + + def test_later_racks_ignore_previous(self, client): + gandalf._cutsheet_schema_ready = True + gandalf.db.cutsheet_racks.return_value = [{'id': 4, 'name': 'A', 'units': 25, 'notes': ''}] + client.post('/api/cutsheet/racks', json={'name': 'B', 'units': 42, 'previous': 'A'}, headers=AUTH) + assert gandalf.db.cutsheet_save_rack.call_args.kwargs == {'previous': ''} + + def test_rack_writes_need_admin_and_valid_data(self, client): + gandalf._cutsheet_schema_ready = True + assert client.post('/api/cutsheet/racks', json={'name': 'x', 'units': 99}, headers=AUTH).status_code == 400 + user = dict(AUTH, **{'Remote-Groups': 'staff'}) + with patch.object(gandalf, '_cfg', dict(CONFIG, auth=dict(CONFIG['auth'], allowed_groups=['admin', 'staff']))): + assert client.put('/api/cutsheet/racks/1', json={'name': 'x', 'units': 2}, headers=user).status_code == 403 + gandalf.db.cutsheet_delete_rack.return_value = False + assert client.delete('/api/cutsheet/racks/9', headers=AUTH).status_code == 404 diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index 9ab7f92..5441ad2 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -321,3 +321,39 @@ class TestDeviceAliases: def test_runs_for_the_device_include_aliased_cables(self): c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'), label='C1') assert [r['label'] for r in cs.runs_for('udm', [c], self.ITEMS, {})] == ['C1'] + + +class TestRackFaces: + def _item(self, name, u, h=1, mount='front', id_=1): + return {'id': id_, 'name': name, 'kind': 'device', 'rack_u': u, 'rack_height': h, 'mount': mount} + + def test_front_and_rear_share_a_unit(self): + items = [self._item('switch', 10), self._item('pdu', 10, mount='rear', id_=2)] + front = cs.rack_layout(items, 25, 'front') + rear = cs.rack_layout(items, 25, 'rear') + assert [i['name'] for i in front['placed']] == ['switch'] and front['overlaps'] == [] + assert [i['name'] for i in rear['placed']] == ['pdu'] and rear['overlaps'] == [] + + def test_full_depth_takes_both_sides(self): + items = [self._item('server', 5, 4, mount='full'), self._item('pdu', 6, mount='rear', id_=2)] + rear = cs.rack_layout(items, 25, 'rear') + assert [(i['name'], i['through']) for i in rear['placed']] == [('server', True)] + assert rear['overlaps'] == [{'name': 'pdu', 'units': 'U6', 'with': ['server']}] + front = cs.rack_layout(items, 25, 'front') + assert [(i['name'], i['through']) for i in front['placed']] == [('server', False)] + assert front['overlaps'] == rear['overlaps'] # reported on both faces + + def test_mount_is_validated_and_shelf_items_take_the_shelf_side(self): + assert cs.validate_item({'name': 'p', 'mount': 'rear', 'rack_u': 3})['mount'] == 'rear' + assert cs.validate_item({'name': 'p'})['mount'] == 'front' + with pytest.raises(cs.ValidationError): + cs.validate_item({'name': 'p', 'mount': 'side'}) + shelf = {'Shelf': {'name': 'Shelf', 'kind': 'shelf'}} + assert cs.validate_item({'name': 'm', 'shelf': 'Shelf', 'mount': 'rear'}, shelf)['mount'] == 'front' + + def test_validate_rack(self): + rack = cs.validate_rack({'name': ' StarTech 25U ', 'units': '25'}) + assert rack == {'name': 'StarTech 25U', 'units': 25, 'notes': ''} + for bad in ({'name': '', 'units': 25}, {'name': 'x'}, {'name': 'x', 'units': 61}, {'name': 'x', 'units': 0}): + with pytest.raises(cs.ValidationError): + cs.validate_rack(bad)