diff --git a/README.md b/README.md index 514149a..9e9f488 100644 --- a/README.md +++ b/README.md @@ -126,17 +126,28 @@ saw them: when each started, how long it lasted, failed checks, and its ticket. (100 per page). ### Rack (`/rack`) -The rack front, drawn from the rack units set on cutsheet items (`rack.units`, default 42U, top first). -- An item named after a UniFi switch or gateway with a faceplate is drawn with its real faceplate and - live port state; patch panels get a generic panel with each port's label; anything else is a labelled - blank plate (desk switches and devices without a faceplate included). +The rack front, drawn from the rack units set on cutsheet items, top first. +- **Linking items to devices**: a cutsheet item can be named after the product ("UDM-Pro") and linked to + the live device with its **Gandalf device** field ("Dream Machine Pro"). Linked UniFi gear with a + faceplate is drawn with its real faceplate and live port state (the UPS 2U Pro shows its front only); + patch panels get a generic panel with each port's label; anything else is a labelled plate with its + live status. Cables may use either name: checks, suggestions, tickets and the device page treat an + 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. - 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. +- Runs of 3 or more empty units fold into one band; "Show empty units" draws them in full (remembered + per browser). Every device is drawn at the full 19" width. +- **Download SVG / PNG** saves the current rack on a white page for printing or planning. - Items that would overlap another or run past the top of the rack are listed, not drawn over. Where two claim the same top unit, the one recorded first is drawn. Items without a rack unit are listed below. -- Adding the new switch: give it a cutsheet item with its rack unit and it appears; it is drawn as a - plate until its model has a faceplate in `static/faceplates.js`. +- The rack keeps its own dark "hardware" palette in both themes. ### Health check (`/health`) No login needed. Returns 200 when the database answers and the monitor has completed a check recently, @@ -436,7 +447,8 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/ | `ticket_api.api_key` | Tinker Tickets bearer token. Needs `read_write` scope to add recovery notes | | `ticket_api.comment_url` | Tinker comment endpoint (default: `api/ticket_comment_api.php` beside `ticket_api.url`) | | `gandalf.public_url` | Gandalf's own address, for links in tickets (default: `https://gandalf.lotusguild.org`) | -| `rack.units` | Height of the rack drawn on `/rack` (default: 42) | +| `rack.units`, `rack.name` | The single rack's height and name on `/rack` (defaults: 42, "Rack 1") | +| `racks` | Several racks: `[{"name": "...", "units": 42}, ...]`, in tab order; overrides `rack` | | `inventory.base_url` | Asset inventory (HomeBox) address; device inventory links must be on this host (default: `https://inventory.lotusguild.org`) | | `inventory.search_url` | Search link used before a device has a record linked, with `{base}` and `{q}` placeholders (default: `{base}/items?q={q}`) | | `pulse.url` | Pulse worker API base URL (for SSH relay) | @@ -700,6 +712,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` and `device` columns; they are added automatically on first use - `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 12cfacf..8f88c74 100644 --- a/app.py +++ b/app.py @@ -933,10 +933,14 @@ def _cutsheet_view() -> dict: ] cable['a_text'] = cutsheet.describe_node(cutsheet._node(cable, 'a')) cable['b_text'] = cutsheet.describe_node(cutsheet._node(cable, 'b')) - data['suggestions'] = cutsheet.undocumented(data['cables'], switches) + data['suggestions'] = cutsheet.undocumented(data['cables'], switches, items) data['labels'] = cutsheet.labels_to_make(data['cables'], items, data['port_labels']) data['next_label'] = cutsheet.next_label(data['cables']) data['catalog'] = _cutsheet_catalog(link_stats, data['items']) + data['racks'] = [r['name'] for r in _racks(data['items'])] + snapshot = _state_json('network_snapshot') + data['devices'] = sorted(set(switches) | set(snapshot.get('hosts') or {}) + | {u['name'] for u in snapshot.get('unifi') or [] if u.get('name')}, key=str.lower) data['verdicts'] = cutsheet.VERDICTS data['options'] = {'kinds': cutsheet.ITEM_KINDS, 'passive_kinds': cutsheet.PASSIVE_KINDS, 'statuses': cutsheet.STATUSES, 'cable_types': cutsheet.CABLE_TYPES} @@ -947,10 +951,12 @@ def _cutsheet_view() -> dict: def _cutsheet_for_device(name: str) -> list: """The cables that end on one device, for its port panel.""" view = _cutsheet_view() + # Cables may name the device or a cutsheet item linked to it + names = {name} | {i['name'] for i in view['items'] if i.get('device') == name} out = [] for cable in view['cables']: for end in ('a', 'b'): - if cable[f'{end}_device'] == name and cable['status'] != 'retired': + if cable[f'{end}_device'] in names and cable['status'] != 'retired': out.append({ 'port': cable[f'{end}_port'], 'side': cable[f'{end}_side'], 'id': cable['id'], 'label': cable['label'], 'status': cable['status'], 'verdict': cable['verdict'], @@ -1066,17 +1072,51 @@ def rack_page(): return render_template('rack.html', user=user, can_edit='admin' in user['groups']) -def _rack_units() -> int: - try: - return max(1, min(int((_config().get('rack') or {}).get('units', 42)), 60)) - except (TypeError, ValueError): - return 42 +def _racks(items: list) -> list: + """Every rack, in order: the configured ones, 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. + """ + def units(value, default=42): + try: + return max(1, min(int(value), 60)) + except (TypeError, ValueError): + return default + + 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()] + if not racks: + racks = [{'name': str(single.get('name') or 'Rack 1'), 'units': default_units}] + 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}) + return racks + + +def _device_status(name: str, snapshot: dict) -> str: + host = (snapshot.get('hosts') or {}).get(name) + if host: + return host.get('status') or 'unknown' + for d in snapshot.get('unifi') or []: + if d.get('name') == name: + return 'up' if d.get('connected') else 'down' + return 'unknown' @app.route('/api/rack') @require_auth def api_rack(): - """The rack front: placed cutsheet items, live switch ports, and each port's cutsheet state.""" + """One rack's front: placed cutsheet items, live switch ports, and each port's cutsheet state. + + Query: rack (name; default the first rack). + """ view = _cutsheet_view() link_stats = _state_json('link_stats') switches = link_stats.get('unifi_switches') or {} @@ -1085,6 +1125,17 @@ def api_rack(): online = {d['name']: d.get('connected', True) for d in snapshot.get('unifi') or []} known = set(switches) | set(online) | set(snapshot.get('hosts') or {}) + racks = _racks(view['items']) + default = racks[0]['name'] + current = next((r for r in racks if r['name'] == request.args.get('rack', default)), None) + if current is None: + return jsonify({'error': 'No such rack'}), 404 + + def rack_of(item): + 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']) 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']) @@ -1092,25 +1143,51 @@ def api_rack(): for pl in view['port_labels']: labels.setdefault(pl['item_name'], {})[str(pl['port'])] = pl['label'] - units = _rack_units() - layout = cutsheet.rack_layout(view['items'], units) + def dev(item): + # An item can be named after the product ("UDM-Pro") and linked to the live device + return item.get('device') or item['name'] + + def url(item): + return f'/device/{quote(dev(item), safe="")}' if dev(item) in known else f'/cutsheet#item-{item["id"]}' + + on_shelf: dict = {} + for item in view['items']: + if item.get('shelf'): + 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']) out = [] for item in layout['placed']: - name = item['name'] + name, device = item['name'], dev(item) entry = {k: item.get(k) for k in ('id', 'name', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack_top', 'location', 'notes')} - entry['url'] = f'/device/{quote(name, safe="")}' if name in known else f'/cutsheet#item-{item["id"]}' - entry['ports'] = {port: m for (dev, port), m in marks.items() if dev == name} + entry['device'] = device + entry['linked'] = device in known + 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 + entry['ports'] = {port: m for (d, port), m in marks.items() if d in (name, device)} entry['labels'] = labels.get(name, {}) - if name in switches: - sw = switches[name] - entry['switch'] = {'model': sw.get('model') or '', 'ports': sw.get('ports') or {}, - 'outlets': (unifi_info.get(name) or {}).get('outlets') or [], - 'online': online.get(name, True)} + model = next((u.get('model') for u in snapshot.get('unifi') or [] if u.get('name') == device), '') + if device in switches or model: + sw = switches.get(device) or {} + entry['switch'] = {'model': sw.get('model') or model or '', 'ports': sw.get('ports') or {}, + 'outlets': (unifi_info.get(device) or {}).get('outlets') or [], + 'online': online.get(device, True)} + if item['kind'] == 'shelf': + 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()) + ] out.append(entry) return jsonify({ - 'units': units, 'items': out, 'overlaps': layout['overlaps'], 'outside': layout['outside'], - 'unplaced': sorted(i['name'] for i in view['items'] if not i.get('rack_u')), + 'racks': racks, 'rack': current['name'], 'units': current['units'], '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'), }) @@ -1151,7 +1228,7 @@ def api_cutsheet_delete_cable(cable_id: int): @require_admin @_cutsheet_write def api_cutsheet_create_item(): - item = cutsheet.validate_item(_json_body()) + item = cutsheet.validate_item(_json_body(), _cutsheet_items()) item_id = db.cutsheet_create_item(item, _get_user()['username']) return jsonify({'success': True, 'id': item_id}) @@ -1161,7 +1238,7 @@ def api_cutsheet_create_item(): @require_admin @_cutsheet_write def api_cutsheet_update_item(item_id: int): - item = cutsheet.validate_item(_json_body()) + item = cutsheet.validate_item(_json_body(), _cutsheet_items()) if not db.cutsheet_update_item(item_id, item, _get_user()['username']): return jsonify({'error': 'No such item'}), 404 return jsonify({'success': True}) diff --git a/cutsheet.py b/cutsheet.py index e4df2fe..cd82cd8 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -32,6 +32,7 @@ NAME_MAX = 100 PORT_MAX = 64 TEXT_MAX = 255 NOTES_MAX = 2000 +RACK_NAME_MAX = 100 # Comparison outcomes, and whether each counts as a problem VERDICTS = { @@ -79,7 +80,8 @@ def _int(data: dict, key: str, low: int, high: int, label: str) -> Optional[int] return number -def validate_item(data: dict) -> dict: +def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict: + """An item from a form. `items` (by name) is needed to check a shelf placement.""" kind = _text(data, 'kind', 20) or 'other' if kind not in ITEM_KINDS: raise ValidationError(f'kind must be one of {", ".join(ITEM_KINDS)}') @@ -90,8 +92,20 @@ def validate_item(data: dict) -> dict: 'location': _text(data, 'location'), 'rack_u': _int(data, 'rack_u', 1, 60, 'Rack unit'), 'rack_height': _int(data, 'rack_height', 1, 20, 'Height'), + 'rack': _text(data, 'rack', RACK_NAME_MAX), + 'device': _text(data, 'device'), + 'shelf': _text(data, 'shelf', NAME_MAX), 'notes': _text(data, 'notes', NOTES_MAX), } + if item['shelf']: + shelf = (items or {}).get(item['shelf']) + if not shelf or shelf['kind'] != 'shelf': + raise ValidationError(f'{item["shelf"]} is not a shelf on the cutsheet') + if kind == 'shelf' or shelf['name'] == item['name']: + 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 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']: @@ -259,6 +273,21 @@ def _same(a: str, b: str) -> bool: return bool(a) and bool(b) and a.strip().lower() == b.strip().lower() +def device_of(name: str, items: Dict[str, dict]) -> str: + """The live device an item name stands for: its linked device, or the name itself.""" + return (items.get(name) or {}).get('device') or name + + +def with_aliases(switches: dict, items: Dict[str, dict]) -> dict: + """Switches also reachable by the name of an item linked to them (item "UDM-Pro" -> "Dream Machine Pro").""" + out = dict(switches or {}) + for name, item in (items or {}).items(): + device = item.get('device') + if device and device in out and name not in out: + out[name] = out[device] + return out + + def _check_end(node: tuple, other: Optional[tuple], switches: dict, items: Dict[str, dict]) -> Optional[dict]: port = _switch_port(node, switches) if port is None: @@ -266,13 +295,13 @@ def _check_end(node: tuple, other: Optional[tuple], switches: dict, items: Dict[ if port == {}: return {'verdict': 'missing_port', 'detail': f'{node[0]} reports no port {node[1]}'} other_passive = other is None or (items.get(other[0], {}).get('kind') in PASSIVE_KINDS) - expected = None if other_passive else other[0] + expected = None if other_passive else device_of(other[0], items) lldp = port.get('lldp') or {} seen = lldp.get('system_name') or '' sfp_peer = (port.get('sfp') or {}).get('cable_peer') if sfp_peer and other is not None and not other_passive: - if _same(sfp_peer.get('switch', ''), other[0]) and str(sfp_peer.get('port_idx')) == other[1]: + if _same(sfp_peer.get('switch', ''), device_of(other[0], items)) and str(sfp_peer.get('port_idx')) == other[1]: return {'verdict': 'match', 'detail': 'Both ends report the same DAC serial'} if not port.get('up'): if expected: @@ -309,7 +338,7 @@ def compare_path(path: dict, cables_by_id: Dict[int, dict], switches: dict, item def evaluate(cables: List[dict], items: Dict[str, dict], switches: dict) -> Dict[int, dict]: """Trace and compare every cable. Returns {cable_id: {'path', 'verdict', 'detail'}}.""" - switches = switches or {} + switches = with_aliases(switches, items) index = node_index(cables) by_id = {c['id']: c for c in cables} result: Dict[int, dict] = {} @@ -345,7 +374,7 @@ def describe_node(node: Optional[tuple]) -> str: # ── Undocumented links ────────────────────────────────────────────────────── -def undocumented(cables: List[dict], switches: dict) -> List[dict]: +def undocumented(cables: List[dict], switches: dict, items: Optional[Dict[str, dict]] = None) -> List[dict]: """Linked switch ports that no live cable mentions, as suggestions to add.""" documented = set() for c in cables: @@ -354,7 +383,7 @@ def undocumented(cables: List[dict], switches: dict) -> List[dict]: for end in ('a', 'b'): node = _node(c, end) if node: - documented.add((node[0].lower(), node[1])) + documented.add((device_of(node[0], items or {}).lower(), node[1])) suggestions = [] for sw_name, sw in sorted((switches or {}).items()): for port_name, port in (sw.get('ports') or {}).items(): @@ -489,7 +518,7 @@ def runs_for(device: str, cables: List[dict], items: Dict[str, dict], switches: if not _live(c): continue for end in ('a', 'b'): - if not _same(c.get(f'{end}_device') or '', device): + if not _same(device_of(c.get(f'{end}_device') or '', items), device): continue if port is not None and str(c.get(f'{end}_port') or '') != str(port): continue diff --git a/db.py b/db.py index ff06e20..375b5a2 100644 --- a/db.py +++ b/db.py @@ -674,6 +674,9 @@ _CUTSHEET_SCHEMA = ( location VARCHAR(255) NOT NULL DEFAULT '', rack_u SMALLINT NULL, rack_height SMALLINT NULL, + rack VARCHAR(100) NOT NULL DEFAULT '', + shelf VARCHAR(100) NOT NULL DEFAULT '', + device VARCHAR(255) NOT NULL DEFAULT '', notes TEXT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -754,7 +757,15 @@ def set_device_link(name: str, inventory_url: str, user: str) -> None: (name, inventory_url, user), ) -_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'notes') +_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'rack', 'shelf', 'device', + '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 ''"), +) _CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side', 'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes') @@ -768,6 +779,12 @@ def ensure_cutsheet_schema() -> None: with conn.cursor() as cur: for statement in _CUTSHEET_SCHEMA: cur.execute(statement) + cur.execute('SHOW COLUMNS FROM cutsheet_items') + have = {r['Field'] for r in cur.fetchall()} + for column, definition in _ITEM_COLUMNS_ADDED: + if column not in have: + # Fixed names from _ITEM_COLUMNS_ADDED, never user input + cur.execute(f'ALTER TABLE cutsheet_items ADD COLUMN {column} {definition}') # nosec B608 def _cs_jsonable(row: Optional[dict]) -> Optional[dict]: @@ -876,17 +893,27 @@ def cutsheet_update_item(item_id: int, item: dict, user: str) -> bool: tuple(item[f] for f in _ITEM_FIELDS) + (item_id,), ) summary = f'Edited {item["name"]}' + if before['kind'] == 'shelf' and item['kind'] != 'shelf': + on_it = _cs_on_shelf(cur, before['name']) + if on_it: + raise CutsheetConflict(f'{before["name"]} still holds {on_it} item(s); move them off the shelf first') if before['name'] != item['name']: for end in ('a', 'b'): cur.execute( f'UPDATE cutsheet_cables SET {end}_device=%s, updated_by=%s WHERE {end}_device=%s', # nosec B608 (item['name'], user, before['name']), ) + cur.execute('UPDATE cutsheet_items SET shelf=%s WHERE shelf=%s', (item['name'], before['name'])) summary = f'Renamed {before["name"]} to {item["name"]}' _cs_log(cur, user, 'item', item_id, 'update', summary, before, item) return True +def _cs_on_shelf(cur, shelf: str) -> int: + cur.execute('SELECT COUNT(*) AS n FROM cutsheet_items WHERE shelf=%s', (shelf,)) + return cur.fetchone()['n'] + + def cutsheet_delete_item(item_id: int, user: str) -> bool: with _cs_transaction() as cur: before = _cs_get(cur, 'cutsheet_items', item_id) @@ -899,6 +926,9 @@ def cutsheet_delete_item(item_id: int, user: str) -> bool: used = cur.fetchone()['n'] if used: raise CutsheetConflict(f'{before["name"]} still has {used} cable(s); move or delete them first') + on_it = _cs_on_shelf(cur, before['name']) + if on_it: + raise CutsheetConflict(f'{before["name"]} still holds {on_it} item(s); move them off the shelf first') cur.execute('DELETE FROM cutsheet_port_labels WHERE item_id=%s', (item_id,)) cur.execute('DELETE FROM cutsheet_items WHERE id=%s', (item_id,)) _cs_log(cur, user, 'item', item_id, 'delete', f'Removed {before["name"]}', before, None) diff --git a/static/cutsheet.js b/static/cutsheet.js index a16daf8..5d6a032 100644 --- a/static/cutsheet.js +++ b/static/cutsheet.js @@ -257,6 +257,13 @@ 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)` : ''}`; + return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where; +} + function renderItems() { const items = [..._cs.items].sort((a, b) => natural(a.kind, b.kind) || natural(a.name, b.name)); document.getElementById('cs-item-count').textContent = items.length; @@ -275,7 +282,7 @@ function renderItems() { ${deviceLink(i.name)} ${esc(KIND_TEXT[i.kind] || i.kind)} ${esc(i.port_count || '–')} - ${i.rack_u ? `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}` : '–'} + ${rackText(i)} ${esc(i.location || '–')} ${esc(i.notes || '')} ${CS_CAN_EDIT ? `` : ''} @@ -382,7 +389,12 @@ function openItem(item) { const form = document.getElementById('cs-item-form'); form.reset(); const data = Object.assign({ kind: 'patch_panel' }, item || {}); - for (const key of ['id', 'name', 'kind', 'port_count', 'rack_u', 'rack_height', 'location', 'notes']) { + 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']) { form[key].value = data[key] == null ? '' : data[key]; } if (!item) form.port_count.value = 24; @@ -435,7 +447,8 @@ async function saveCable(e) { async function saveItem(e) { e.preventDefault(); const form = e.target; - const body = formData(form, ['name', 'kind', 'port_count', 'rack_u', 'rack_height', 'location', 'notes']); + const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', '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/faceplates.js b/static/faceplates.js index 93f28e3..4974235 100644 --- a/static/faceplates.js +++ b/static/faceplates.js @@ -413,6 +413,7 @@ const FACEPLATES = { // The outlets are on the rear, and the touchscreen shows a tile for each one. USPDA2B: { width: 1086, height: 460, frameClass: 'fp-scroll fp-stack', scrollX: 340, noBody: true, + rackView: [0, 0, 1086, 210], // the front only, for the rack drawing label: 'UPS 2U Pro', legend: [['outlet-on', 'outlet on'], ['outlet-off', 'outlet off'], ['blue', 'online']], ports: [], @@ -495,7 +496,8 @@ function faceplateLegendHtml(spec, marks) { `; } -// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text} } +// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text}, +// rack? (draw only the front, spec.rackView, as mounted in a rack) } function renderFaceplate(model, ctx) { const spec = FACEPLATES[model]; const anyPort = Object.values(ctx.portMap)[0]; @@ -516,7 +518,7 @@ function renderFaceplate(model, ctx) { : ``; return `
- + diff --git a/static/rack.js b/static/rack.js index 4d62898..5dd961e 100644 --- a/static/rack.js +++ b/static/rack.js @@ -41,7 +41,7 @@ function isRackFaceplate(model) { function switchHtml(item) { const sw = item.switch; return renderFaceplate(sw.model, { - swName: item.name, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, + swName: item.device, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, rack: true, titleFor: (port, idx) => portTitle(port, idx), cutsheetFor: idx => { const m = item.ports[String(idx)]; @@ -86,10 +86,30 @@ function panelSvg(item) { `; } +const STATUS_CLASS = { up: 'dot-up', down: 'dot-down', degraded: 'dot-degraded' }; + +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'}
+
`; +} + function plateHtml(item) { - const sub = [KIND_TEXT[item.kind] || item.kind, item.location].filter(Boolean).join(' · '); + const unlinked = item.kind === 'device' && !item.linked; + const sub = [item.device !== item.name ? item.device : '', KIND_TEXT[item.kind] || item.kind, item.location, + unlinked ? 'not linked to a Gandalf device' : ''].filter(Boolean).join(' · '); return `
- ${esc(item.name)} + ${statusDot(item.status)}${esc(item.name)} ${esc(sub)}
`; } @@ -98,6 +118,7 @@ function itemHtml(item) { let body; 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); return `
${body}
`; } @@ -108,6 +129,21 @@ function uLabels(top, bottom) { return ``; } +// Long runs of empty units are folded into one thin band unless the viewer asks for them +const GAP_MIN = 3; + +function showEmpty() { + try { return localStorage.getItem('gandalf.rackShowEmpty') === '1'; } catch (_) { return false; } +} + +function gapRow(top, bottom) { + const n = top - bottom + 1; + return `
+ +
U${bottom}–${top} · ${n}U free
+
`; +} + function row(top, bottom, inner, cls) { const n = top - bottom + 1; return `
@@ -117,8 +153,20 @@ function row(top, bottom, inner, cls) { } // ── Page ──────────────────────────────────────────────────────────────── +function renderTabs(data) { + const tabs = document.getElementById('rack-tabs'); + tabs.hidden = data.racks.length < 2; + tabs.innerHTML = data.racks.map(r => { + const on = r.name === data.rack; + return ``; + }).join(''); + document.getElementById('rack-title').textContent = data.racks.length > 1 ? `${data.rack} · front` : 'Front'; +} + function render(data) { _rack = data; + renderTabs(data); const byTop = Object.fromEntries(data.items.map(i => [i.rack_top, i])); const rows = []; let u = data.units; @@ -131,15 +179,18 @@ function render(data) { } let low = u; while (low - 1 >= 1 && !byTop[low - 1]) low--; - rows.push(row(u, low, '', 'rack-row-empty')); + rows.push(!showEmpty() && u - low + 1 >= GAP_MIN ? gapRow(u, low) : row(u, low, '', 'rack-row-empty')); 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')); + document.getElementById('rack-show-empty').checked = showEmpty(); document.getElementById('rack-count').textContent = data.items.length; // Faceplate ports carry their cable run too, for hover and click document.querySelectorAll('.rack-item .fp-port').forEach(g => { - const item = data.items.find(i => i.name === g.dataset.switch); + const item = data.items.find(i => i.device === g.dataset.switch); const m = item && item.ports[g.dataset.portIdx]; if (m) { g.dataset.run = m.run.join(' '); @@ -228,9 +279,23 @@ rackEl.addEventListener('keydown', e => { } }); +function currentRack() { + return new URLSearchParams(location.search).get('rack') || ''; +} + +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(); +}); + async function load() { try { - render(await lt.api.get('/api/rack')); + const rack = currentRack(); + render(await lt.api.get(`/api/rack${rack ? `?rack=${encodeURIComponent(rack)}` : ''}`)); } catch (err) { if (_rack) return; // keep the last drawing on a failed refresh document.getElementById('rack').innerHTML = `
@@ -242,3 +307,132 @@ async function load() { readout(null); load(); setInterval(() => { if (!document.hidden && !document.querySelector('#rack .rack-hl')) load(); }, 60000); + +// ── Export (SVG / PNG) ────────────────────────────────────────────────── +// Rebuilds the drawing as one standalone SVG on a white page, for printing or planning. +// Faceplates and panels are copied from the page with their computed styles inlined, +// since the stylesheet doesn't travel with the file. +const SVG_NS = 'http://www.w3.org/2000/svg'; +const STYLE_PROPS = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', + 'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-size', 'font-family', 'font-weight', 'letter-spacing', + 'text-anchor', 'dominant-baseline', 'display', 'visibility']; + +function inlineStyles(src, dst) { + const cs = getComputedStyle(src); + dst.setAttribute('style', STYLE_PROPS.map(p => `${p}:${cs.getPropertyValue(p)}`).join(';')); + dst.removeAttribute('class'); + for (let i = 0; i < src.children.length; i++) inlineStyles(src.children[i], dst.children[i]); +} + +function svgEl(tag, attrs, text) { + const el = document.createElementNS(SVG_NS, tag); + Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v)); + if (text != null) el.textContent = text; + return el; +} + +function buildExport() { + document.querySelectorAll('#rack .rack-hl').forEach(el => el.classList.remove('rack-hl')); + const W = 1100, gutter = 46, pad = 20, head = 44, unit = W / RATIO; + const rows = [...document.querySelectorAll('#rack .rack-row')]; + const height = rows.reduce((h, r) => h + (r.dataset.gap ? 26 : parseInt(r.style.getPropertyValue('--n'), 10) * unit), 0); + const H = head + height + pad * 2; + const root = svgEl('svg', { xmlns: SVG_NS, width: W + gutter + pad * 2, height: H, + viewBox: `0 0 ${W + gutter + pad * 2} ${H}`, 'font-family': 'Helvetica, Arial, sans-serif' }); + 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`)); + root.appendChild(svgEl('text', { x: W + gutter + pad, y: pad + 14, 'font-size': 11, fill: '#666', 'text-anchor': 'end' }, + `Gandalf · ${stamp}`)); + + let y = head + pad; + const x = pad + gutter; + rows.forEach(row => { + const n = parseInt(row.style.getPropertyValue('--n'), 10); + if (row.dataset.gap) { + const band = 26; + root.appendChild(svgEl('rect', { x, y: y + 2, width: W, height: band - 4, fill: '#f4f5f7', stroke: '#d5d9de', + 'stroke-dasharray': '4 4' })); + root.appendChild(svgEl('text', { x: x + W / 2, y: y + band / 2 + 4, 'font-size': 11, fill: '#777', + 'text-anchor': 'middle' }, row.textContent.trim())); + y += band; + return; + } + const h = n * unit; + [...row.querySelectorAll('.rack-u span')].forEach((s, i) => { + root.appendChild(svgEl('text', { x: x - 8, y: y + unit * i + unit / 2 + 4, 'font-size': 11, fill: '#777', + 'text-anchor': 'end' }, s.textContent)); + }); + const art = row.querySelector('svg.fp-svg, svg.rack-svg'); + const plate = row.querySelector('.rack-plate'); + if (art) { + 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.removeAttribute('style'); + root.appendChild(clone); + } else if (plate) { + root.appendChild(svgEl('rect', { x, y: y + 1, width: W, height: h - 2, rx: 3, fill: '#e9ecef', stroke: '#9aa3ad' })); + const name = plate.querySelector('.rack-plate-name, .rack-shelf-label'); + const things = [...plate.querySelectorAll('.rack-shelf-name')].map(t => t.textContent); + const sub = things.length ? things.join(' · ') : (plate.querySelector('.rack-plate-sub') || {}).textContent || ''; + root.appendChild(svgEl('text', { x: x + 16, y: y + h / 2 + (sub ? -2 : 5), 'font-size': 15, 'font-weight': 700, + fill: '#111' }, name ? name.textContent : '')); + if (sub) root.appendChild(svgEl('text', { x: x + 16, y: y + h / 2 + 15, 'font-size': 11, fill: '#444' }, sub)); + } else { + root.appendChild(svgEl('rect', { x, y: y + 1, width: W, height: h - 2, fill: 'none', stroke: '#d5d9de', + 'stroke-dasharray': '4 4' })); + } + y += h; + }); + return new XMLSerializer().serializeToString(root); +} + +function download(blob, name) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 10000); +} + +async function exportRack(kind) { + if (!_rack || !_rack.items.length) { + lt.toast.error('Nothing in this rack to export yet'); + return; + } + const svg = buildExport(); + const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-rack`; + if (kind === 'svg') { + download(new Blob([svg], { type: 'image/svg+xml' }), `${base}.svg`); + return; + } + const img = new Image(); + const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })); + try { + await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; }); + const scale = 2; + const canvas = document.createElement('canvas'); + canvas.width = img.width * scale; + canvas.height = img.height * scale; + const ctx = canvas.getContext('2d'); + ctx.scale(scale, scale); + ctx.drawImage(img, 0, 0); + canvas.toBlob(blob => download(blob, `${base}.png`), 'image/png'); + } catch (err) { + lt.toast.error('Could not render the PNG; try the SVG'); + } finally { + URL.revokeObjectURL(url); + } +} + +document.getElementById('rack-show-empty').addEventListener('change', e => { + try { localStorage.setItem('gandalf.rackShowEmpty', e.target.checked ? '1' : '0'); } catch (_) { /* private mode */ } + if (_rack) render(_rack); +}); + +document.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', () => exportRack(btn.dataset.export))); diff --git a/static/style.css b/static/style.css index 8353750..09e9efe 100644 --- a/static/style.css +++ b/static/style.css @@ -1433,15 +1433,23 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .fp-mark-toggle input { margin: 0; } /* ── Rack (/rack) ──────────────────────────────────────────────────── */ -.rack { max-width: 1000px; display: flex; flex-direction: column; gap: 2px; padding: 10px 12px 10px 0; - background: #0b0f14; border: 1px solid var(--border-color); border-radius: 4px; } +/* The rack is hardware: it keeps its own palette in both themes so labels inside it stay readable */ +.rack { + --rk-frame: #0b0f14; --rk-edge: #2a3440; --rk-text: #e6edf3; --rk-muted: #8b97a5; --rk-line: rgba(255,255,255,.07); + --rk-plate-a: #232a33; --rk-plate-b: #1a2028; --rk-plate-edge: #2d3743; --rk-link: #6fd3ff; + max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; gap: 2px; padding: 10px 12px 10px 0; + background: var(--rk-frame); border: 1px solid var(--rk-edge); border-radius: 4px; color: var(--rk-text); } +[data-theme="light"] .rack { + --rk-frame: #2b3139; --rk-edge: #1d2228; --rk-muted: #a9b4c0; --rk-line: rgba(255,255,255,.10); + --rk-plate-a: #3a424d; --rk-plate-b: #313841; --rk-plate-edge: #4a5461; --rk-link: #8fdcff; + box-shadow: 0 6px 18px rgba(20,30,45,.25); } .rack-row { display: grid; grid-template-columns: 34px 1fr; } -.rack-u { display: flex; flex-direction: column; font-size: .62rem; color: var(--text-muted); text-align: right; +.rack-u { display: flex; flex-direction: column; font-size: .62rem; color: var(--rk-muted); text-align: right; padding-right: 6px; font-family: var(--font-mono, monospace); } .rack-u span { flex: 1; display: flex; align-items: center; justify-content: flex-end; } .rack-slot { position: relative; min-width: 0; } .rack-row-empty .rack-slot { background: repeating-linear-gradient(to bottom, transparent 0 calc(100% / var(--n) - 1px), - rgba(255,255,255,.05) calc(100% / var(--n) - 1px) calc(100% / var(--n))); border: 1px dashed rgba(255,255,255,.06); } + var(--rk-line) calc(100% / var(--n) - 1px) calc(100% / var(--n))); border: 1px dashed var(--rk-line); } .rack-item, .rack-item .fp-wrap, .rack-item .fp-frame { height: 100%; } .rack-item .fp-wrap { padding: 0; } .rack-item .fp-frame { box-shadow: none; overflow: hidden; } @@ -1449,26 +1457,29 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .rack-item .chassis-legend { display: none; } .rack-svg { display: block; width: 100%; height: 100%; } .rack-plate { height: 100%; display: flex; align-items: center; gap: 14px; padding: 0 18px; border-radius: 3px; - background: linear-gradient(#232a33, #1a2028); border: 1px solid #2d3743; } -.rack-plate-name { font-weight: 700; } -.rack-plate-sub { font-size: .78rem; color: var(--text-muted); } -.rack-kind-shelf .rack-plate { background: repeating-linear-gradient(90deg, #1b2129 0 10px, #161b22 10px 20px); } + background: linear-gradient(var(--rk-plate-a), var(--rk-plate-b)); border: 1px solid var(--rk-plate-edge); } +.rack .rack-plate-name { font-weight: 700; color: var(--rk-text); text-decoration-color: var(--rk-muted); } +.rack .rack-plate-name:hover, .rack .rack-plate-name:focus-visible { color: var(--rk-link); } +.rack-plate-sub { font-size: .78rem; color: var(--rk-muted); } +.rack-kind-shelf .rack-plate { background: repeating-linear-gradient(90deg, var(--rk-plate-b) 0 10px, var(--rk-frame) 10px 20px); } .rack-pp-body { fill: #1d2229; stroke: #39424e; } .rack-pp-name { fill: #9aa7b4; font-size: 15px; font-family: var(--font-mono, monospace); } .rack-pp-num { fill: #7d8a97; font-size: 10px; } .rack-pp-label { fill: #c9d3dc; font-size: 9px; } .rack-pp-jack { fill: #0c0f13; stroke: #4a5560; stroke-width: 1.5; } -.rack-pp-ok .rack-pp-jack { stroke: var(--accent-green); } +.rack-pp-ok .rack-pp-jack { stroke: #00e07a; } .rack-pp-bad .rack-pp-jack { stroke: var(--status-critical, #e0312b); stroke-width: 2.5; } .rack-pp-doc .rack-pp-jack { stroke: #9aa7b4; } .rack-pp-hit { fill: transparent; } .rack-pp-port[data-cable] { cursor: pointer; outline: none; } /* A hovered run: every port on it */ -.rack-pp-port.rack-hl .rack-pp-jack { fill: rgba(0,212,255,.35); stroke: var(--accent-cyan); stroke-width: 3; } -.rack .fp-port.rack-hl .fp-hit { stroke: var(--accent-cyan); stroke-width: 4; fill: rgba(0,212,255,.3); } +.rack-pp-port.rack-hl .rack-pp-jack { fill: rgba(0,212,255,.35); stroke: #00d4ff; stroke-width: 3; } +.rack .fp-port.rack-hl .fp-hit { stroke: #00b8e6; stroke-width: 4; fill: rgba(0,212,255,.3); } +.rack-readout, .rack-legend, .rack-unplaced, .rack-tabs, .rack-warnings { max-width: 1000px; + margin-left: auto !important; margin-right: auto !important; } .rack-readout { min-height: 1.4em; margin: 0 0 8px; font-size: .85rem; color: var(--accent-cyan); } .rack-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: .75rem; color: var(--text-muted); margin-bottom: 8px; } .rack-key { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: -1px; } @@ -1480,9 +1491,27 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .rack-warnings p { margin: 0; } .rack-unplaced { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } .rack-empty { margin: 12px; } +.rack-gap { height: 26px; display: flex; align-items: center; justify-content: center; font-size: .72rem; + color: var(--rk-muted); border: 1px dashed var(--rk-line); letter-spacing: .04em; } +.rack-toggle { margin-left: auto; display: inline-flex; gap: 6px; align-items: center; cursor: pointer; } +.rack-legend { align-items: center; } @media (max-width: 600px) { .rack-row { grid-template-columns: 22px 1fr; } .rack-u { font-size: .5rem; padding-right: 3px; } .rack-plate { padding: 0 8px; gap: 8px; } .rack-plate-sub { display: none; } } +.rack-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } +.rack-tab { background: none; border: 1px solid var(--border-color); color: inherit; font: inherit; font-size: .85rem; + padding: 6px 12px; cursor: pointer; border-radius: 3px; } +.rack-tab.active { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); } +.rack-tab-count { color: var(--text-muted); font-size: .75rem; margin-left: 4px; } +.rack-shelf { gap: 10px; padding: 0 10px; container-type: size; } +.rack-shelf-label { font-size: .72rem; color: var(--rk-muted); white-space: nowrap; writing-mode: horizontal-tb; } +.rack-shelf-things { display: flex; gap: 6px; flex: 1; min-width: 0; height: 78%; } +.rack-shelf-thing { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 6px; + padding: 0 6px; border-radius: 3px; background: linear-gradient(#e6e8eb, #c9cdd2); color: #15191e; + text-decoration: none; font-size: .78rem; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.5); } +.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; } } diff --git a/templates/cutsheet.html b/templates/cutsheet.html index 89bcb1e..12a30d1 100644 --- a/templates/cutsheet.html +++ b/templates/cutsheet.html @@ -229,7 +229,13 @@
-
For a device's rack position, use its exact Gandalf name.
+
+
+ + + +
Which live device this is, when the name above is different (e.g. name "UDM-Pro", device "Dream Machine Pro"). Its faceplate, ports and status then show on the rack.
@@ -257,6 +263,18 @@
+
+
+ + + +
+
+ + +
+
+
Gear on a shelf (mini PCs, desk switches) takes its place from the shelf: leave its rack unit empty.
diff --git a/templates/rack.html b/templates/rack.html index 31ca46b..8cfdc88 100644 --- a/templates/rack.html +++ b/templates/rack.html @@ -7,18 +7,22 @@

Rack

The rack front, drawn from the rack units on the cutsheet with live port state. Hover a port to light up every port on the same cable run; click it to open the port or the cable.

- {% if can_edit %}
+ + + {% if can_edit %} Edit positions + {% endif %}
- {% endif %}
+ +
-

Front

+

Front

0

@@ -26,6 +30,7 @@ matches cutsheet mismatch documented, unconfirmed +

diff --git a/tests/test_app.py b/tests/test_app.py index eb886ce..c6be463 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -456,7 +456,7 @@ class TestRackApi: pp, sw = data['items'] assert pp['labels'] == {'5': 'OFFICE'} and pp['url'] == '/cutsheet#item-2' assert pp['ports']['5']['run'] == [7] and pp['ports']['5']['cable_id'] == 7 - assert sw['url'] == '/device/USW%20Flex' and 'switch' not in sw # no port data in this snapshot + assert sw['url'] == '/device/USW%20Flex' and sw['switch']['model'] == 'USF5P' # model from UniFi assert sw['ports']['2']['mark'] in ('ok', 'bad', 'doc') def test_rack_page_and_auth(self, client): @@ -468,3 +468,44 @@ class TestRackApi: gandalf.db.cutsheet_load.return_value = {'items': [], 'cables': [], 'port_labels': []} with patch.object(gandalf, '_cfg', dict(CONFIG, rack={'units': 24})): assert client.get('/api/rack', headers=AUTH).get_json()['units'] == 24 + + +class TestRackRacksShelvesLinks: + def _items(self, *items): + base = {'port_count': None, 'rack_u': None, 'rack_height': None, 'rack': '', 'shelf': '', 'device': '', + '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_product_named_item_links_to_its_device(self, client): + self._items({'name': 'Flex (desk)', 'kind': 'device', 'rack_u': 5, 'rack_height': 1, 'device': 'USW Flex'}) + item = client.get('/api/rack', headers=AUTH).get_json()['items'][0] + assert item['device'] == 'USW Flex' and item['linked'] and item['url'] == '/device/USW%20Flex' + assert item['status'] == 'up' and item['switch']['model'] == 'USF5P' + + def test_unlinked_item(self, client): + self._items({'name': 'UNVR-Pro', 'kind': 'device', 'rack_u': 5, 'rack_height': 2}) + item = client.get('/api/rack', headers=AUTH).get_json()['items'][0] + assert not item['linked'] and item['url'] == '/cutsheet#item-1' and 'switch' not in item + + def test_shelf_lists_what_is_on_it(self, client): + self._items({'name': 'Shelf', 'kind': 'shelf', 'rack_u': 3, 'rack_height': 1}, + {'name': 'large1', 'kind': 'device', 'shelf': 'Shelf'}, + {'name': 'Mini', 'kind': 'device', 'shelf': 'Shelf', 'device': 'USW Flex'}) + data = client.get('/api/rack', headers=AUTH).get_json() + assert [i['name'] for i in data['items']] == ['Shelf'] and data['unplaced'] == [] + assert [(c['name'], c['status']) for c in data['items'][0]['contents']] == [('large1', 'up'), ('Mini', 'up')] + + def test_racks_tabs_and_selection(self, client): + self._items({'name': 'A', 'kind': 'other', 'rack_u': 1, 'rack_height': 1}, + {'name': 'B', 'kind': 'other', 'rack_u': 1, 'rack_height': 1, 'rack': 'Garage'}) + 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 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 diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index 96ea8cf..9ab7f92 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -296,3 +296,28 @@ class TestPortMarks: dict(cable(2, ('PP1', '1', 'rear'), ('jack', '1')), verdict='idle', detail='Idle', run=[1, 2]), ] assert cs.port_marks(cables)[('PP1', '1')]['text'] == 'cables C1, #2: Idle' + + +class TestDeviceAliases: + ITEMS = {'UDM-Pro': {'name': 'UDM-Pro', 'kind': 'device', 'device': 'udm'}, + 'Big box': {'name': 'Big box', 'kind': 'device', 'device': 'large1'}} + + def test_cable_on_an_item_is_checked_against_its_device(self): + switches = {'udm': switch([port(3, seen='large1')])['sw']} + c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0')) + assert cs.evaluate([c], self.ITEMS, switches)[1]['verdict'] == 'match' + + def test_wrong_neighbor_still_caught_through_an_alias(self): + switches = {'udm': switch([port(3, seen='printer')])['sw']} + c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0')) + assert cs.evaluate([c], self.ITEMS, switches)[1]['verdict'] == 'wrong_neighbor' + + def test_aliased_cable_counts_as_documented(self): + switches = {'udm': switch([port(3, seen='large1')])['sw']} + c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0')) + assert cs.undocumented([c], switches, self.ITEMS) == [] + assert len(cs.undocumented([c], switches)) == 1 + + 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']