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)