From f655836446141289ecce817502215c872b0ea172 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Nov 2026 20:59:30 -0500 Subject: [PATCH] Add a rack drawing (/rack) The rack front is drawn from the rack units on cutsheet items. Switches with a faceplate use it, with live port state; patch panels get a generic panel with port labels; everything else is a labelled plate. Each documented port shows its cutsheet state, and 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. Overlapping items and items past the top of the rack are listed instead of drawn over another; where two claim the same top unit, the first one recorded wins. rack.units sets the height (default 42). /api/rack builds on the cutsheet view; cutsheet.rack_layout and cutsheet.port_marks hold the placement and per-port logic. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 +++ app.py | 60 ++++++++++ cutsheet.py | 62 ++++++++++ static/rack.js | 244 ++++++++++++++++++++++++++++++++++++++++ static/style.css | 55 +++++++++ templates/base.html | 9 ++ templates/cutsheet.html | 1 + templates/rack.html | 38 +++++++ tests/test_app.py | 41 +++++++ tests/test_cutsheet.py | 48 ++++++++ 10 files changed, 574 insertions(+) create mode 100644 static/rack.js create mode 100644 templates/rack.html diff --git a/README.md b/README.md index 0b34056..514149a 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,19 @@ saw them: when each started, how long it lasted, failed checks, and its ticket. - API: `GET /api/events/history?range=24h|7d|30d|90d&type=&target=&severity=&status=all|active|resolved&offset=` (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). +- 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. +- 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`. + ### Health check (`/health`) No login needed. Returns 200 when the database answers and the monitor has completed a check recently, otherwise 503 with the failing check: `{"status": "degraded", "checks": {"db": "ok", "monitor": "stale", @@ -423,6 +436,7 @@ 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) | | `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) | @@ -642,6 +656,7 @@ gandalf/ │ ├── device.js # Device page rendering │ ├── cutsheet.js # Cutsheet page │ ├── events.js # Alert history page +│ ├── rack.js # Rack drawing │ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics └── templates/ ├── base.html # Shared layout (header, nav, footer) @@ -649,6 +664,7 @@ gandalf/ ├── device.html # Per-device page ├── cutsheet.html # Cutsheet page and its editors ├── events.html # Alert history page + ├── rack.html # Rack drawing ├── _modem_panel.html # WAN / Fiber panel, shared by the dashboard and modem page └── suppressions.html # Suppression management page ``` diff --git a/app.py b/app.py index fcb8e6d..12cfacf 100644 --- a/app.py +++ b/app.py @@ -1055,6 +1055,66 @@ def api_device_inventory(name: str): return jsonify({'success': True, 'url': url}) +# --------------------------------------------------------------------------- +# Rack elevation +# --------------------------------------------------------------------------- + +@app.route('/rack') +@require_auth +def rack_page(): + user = _get_user() + 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 + + +@app.route('/api/rack') +@require_auth +def api_rack(): + """The rack front: placed cutsheet items, live switch ports, and each port's cutsheet state.""" + view = _cutsheet_view() + link_stats = _state_json('link_stats') + switches = link_stats.get('unifi_switches') or {} + unifi_info = link_stats.get('unifi_devices') or {} + snapshot = _state_json('network_snapshot') + online = {d['name']: d.get('connected', True) for d in snapshot.get('unifi') or []} + known = set(switches) | set(online) | set(snapshot.get('hosts') or {}) + + 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']) + labels: dict = {} + 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) + out = [] + for item in layout['placed']: + name = item['name'] + 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['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)} + 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')), + 'updated': link_stats.get('updated'), + }) + + @app.route('/api/cutsheet/cables', methods=['POST']) @require_auth @require_admin diff --git a/cutsheet.py b/cutsheet.py index a8e08d3..e4df2fe 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -542,3 +542,65 @@ def problems(cables: List[dict], items: Dict[str, dict], switches: dict) -> List 'path': path_text(hops), }) return found + + +# ── Rack elevation ────────────────────────────────────────────────────────── + +def rack_layout(items: List[dict], units: int) -> dict: + """Place items that have a rack unit, top of the rack first. + + 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]}. + """ + taken: Dict[int, 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 + for item in sorted(racked, key=lambda i: (-(i['rack_u'] + (i.get('rack_height') or 1)), i.get('id') or 0)): + low = item['rack_u'] + high = low + (item.get('rack_height') or 1) - 1 + span = f'U{low}' if high == low else f'U{low}–{high}' + 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}) + 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)) + return {'placed': placed, 'overlaps': overlaps, 'outside': outside} + + +_MARK_RANK = {'bad': 3, 'doc': 2, 'ok': 1} + + +def port_marks(cables: List[dict]) -> Dict[tuple, dict]: + """Per (device, port): the worst check of the live cables plugged in there. + + `cables` are evaluated cables (with 'verdict', 'detail' and 'run', the IDs of + every cable in its run). Returns {(device, port): {'mark', 'text', 'cable_id', 'run'}}, + where mark is ok (matches), bad (a problem) or doc (documented, not confirmed). + """ + out: Dict[tuple, dict] = {} + for c in cables: + if not _live(c): + continue + mark = 'bad' if c['verdict'] in PROBLEM_VERDICTS else ('ok' if c['verdict'] == 'match' else 'doc') + label = c.get('label') or f'#{c["id"]}' + for end in ('a', 'b'): + device, port = c.get(f'{end}_device'), c.get(f'{end}_port') + if not device or not port: + continue + entry = out.setdefault((device, str(port)), {'mark': mark, 'cable_id': c['id'], 'run': [], 'by_detail': {}}) + if _MARK_RANK[mark] > _MARK_RANK[entry['mark']]: + entry.update(mark=mark, cable_id=c['id']) + entry['by_detail'].setdefault(c['detail'], []).append(label) + entry['run'] = sorted(set(entry['run']) | set(c.get('run') or [c['id']])) + for entry in out.values(): + # A panel port's front and rear cables share one check: say it once + 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 diff --git a/static/rack.js b/static/rack.js new file mode 100644 index 0000000..4d62898 --- /dev/null +++ b/static/rack.js @@ -0,0 +1,244 @@ +'use strict'; +/* global FACEPLATES, renderFaceplate */ +// 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. + +const esc = s => lt.escHtml(s == null ? '' : String(s)); +const RATIO = 10.5; // a 19" rack's width to one unit's height +const KIND_TEXT = { patch_panel: 'Patch panel', wall_jack: 'Wall jack', device: 'Device', shelf: 'Shelf', other: 'Other' }; + +let _rack = null; + +function speedText(mbps) { + if (!mbps) return ''; + return mbps >= 1000 ? `${mbps / 1000}G` : `${mbps}M`; +} + +function portMapOf(ports) { + const map = {}; + Object.entries(ports || {}).forEach(([name, p]) => { + if (p.port_idx != null) map[p.port_idx] = Object.assign({ name }, p); + }); + return map; +} + +function portTitle(port, idx) { + const parts = [port && port.name && port.name !== `Port ${idx}` ? `Port ${idx} "${port.name}"` : `Port ${idx}`]; + if (port) { + parts.push(port.up ? (speedText(port.speed_mbps) || 'up') : 'down'); + const nb = port.lldp && (port.lldp.system_name || port.lldp.chassis_id); + if (nb) parts.push(`→ ${nb}`); + } + return esc(parts.join(' · ')); +} + +function isRackFaceplate(model) { + const spec = FACEPLATES[model]; + return !!spec && !/fp-desk/.test(spec.frameClass || ''); +} + +// ── Item drawings ─────────────────────────────────────────────────────── +function switchHtml(item) { + const sw = item.switch; + return renderFaceplate(sw.model, { + swName: item.name, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, + titleFor: (port, idx) => portTitle(port, idx), + cutsheetFor: idx => { + const m = item.ports[String(idx)]; + return m ? { mark: m.mark, text: m.text } : null; + }, + }); +} + +function panelSvg(item) { + const count = Math.max(1, item.port_count || 24); + const units = item.rack_height || 1; + const rows = Math.max(1, Math.min(units * 2, Math.ceil(count / 24))); + const perRow = Math.ceil(count / rows); + const W = 1100, H = 105 * units, ear = 70; + const pitch = Math.min(38, (W - ear * 2 - 40) / perRow); + const left = (W - pitch * perRow) / 2; + const rowH = (H - 20) / rows; + // Label characters that fit under one port at the label's font size (9px, ~5.6px a character) + const fit = Math.max(3, Math.floor((pitch - 2) / 5.6)); + const short = t => (t.length > fit ? `${t.slice(0, fit - 1)}…` : t); + const ports = []; + for (let n = 1; n <= count; n++) { + const r = Math.floor((n - 1) / perRow), c = (n - 1) % perRow; + const x = left + c * pitch + pitch / 2, y = 10 + r * rowH + rowH / 2; + const m = item.ports[String(n)]; + const label = item.labels[String(n)] || ''; + const title = [`Port ${n}`, label && `"${label}"`, m ? m.text : 'not on the cutsheet'].filter(Boolean).join(' · '); + const attrs = m ? ` data-run="${m.run.join(' ')}" data-cable="${m.cable_id}" tabindex="0" role="link"` : ''; + ports.push(` + ${esc(title)} + ${n} + + + ${label ? `${esc(short(label))}` : ''} + `); + } + return ` + + ${esc(item.name)} + ${ports.join('')} + `; +} + +function plateHtml(item) { + const sub = [KIND_TEXT[item.kind] || item.kind, item.location].filter(Boolean).join(' · '); + return `
+ ${esc(item.name)} + ${esc(sub)} +
`; +} + +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 body = plateHtml(item); + return `
${body}
`; +} + +function uLabels(top, bottom) { + const spans = []; + for (let u = top; u >= bottom; u--) spans.push(`${u}`); + return ``; +} + +function row(top, bottom, inner, cls) { + const n = top - bottom + 1; + return `
+ ${uLabels(top, bottom)} +
${inner}
+
`; +} + +// ── Page ──────────────────────────────────────────────────────────────── +function render(data) { + _rack = data; + const byTop = Object.fromEntries(data.items.map(i => [i.rack_top, i])); + const rows = []; + let u = data.units; + while (u >= 1) { + const item = byTop[u]; + if (item) { + rows.push(row(u, item.rack_u, itemHtml(item), 'rack-row-item')); + u = item.rack_u - 1; + continue; + } + let low = u; + while (low - 1 >= 1 && !byTop[low - 1]) low--; + rows.push(row(u, low, '', 'rack-row-empty')); + u = low - 1; + } + document.getElementById('rack').innerHTML = rows.join(''); + 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 m = item && item.ports[g.dataset.portIdx]; + if (m) { + g.dataset.run = m.run.join(' '); + g.dataset.cable = m.cable_id; + } + }); + + const notes = []; + data.overlaps.forEach(o => notes.push(`${esc(o.name)} (${esc(o.units)}) overlaps ${esc(o.with.join(', '))} and is not drawn.`)); + data.outside.forEach(o => notes.push(`${esc(o.name)} (${esc(o.units)}) is above the top of a ${data.units}U rack and is not drawn.`)); + const warn = document.getElementById('rack-warnings'); + warn.hidden = !notes.length; + warn.innerHTML = notes.length ? ` +

Fix the rack unit on the cutsheet.

` : ''; + + const unplaced = document.getElementById('rack-unplaced'); + unplaced.innerHTML = data.unplaced.length + ? `Not in the drawing (no rack unit set): ${data.unplaced.map(n => `${esc(n)}`).join(' ')}` + : ''; + + if (!data.items.length) { + document.getElementById('rack').insertAdjacentHTML('afterbegin', + `
Nothing placed yet
+
Give panels, shelves and devices a rack unit on the cutsheet and they appear here.
`); + } +} + +function readout(el) { + const out = document.getElementById('rack-readout'); + if (!el) { + out.textContent = 'Hover a port to follow its cable.'; + return; + } + const title = el.querySelector('title'); + out.textContent = title ? title.textContent : ''; +} + +function highlight(el) { + const ids = el && el.dataset.run ? el.dataset.run.split(' ') : []; + document.querySelectorAll('#rack [data-run]').forEach(x => { + x.classList.toggle('rack-hl', ids.length > 0 && x.dataset.run.split(' ').some(i => ids.includes(i))); + }); +} + +function portAt(target) { + return target.closest && target.closest('#rack .fp-port, #rack .rack-pp-port'); +} + +function onEnter(e) { + const el = portAt(e.target); + if (!el) return; + highlight(el); + readout(el); +} + +function onLeave(e) { + const el = portAt(e.target); + if (el && !el.contains(e.relatedTarget)) { + highlight(null); + readout(null); + } +} + +function openPort(el) { + if (el.classList.contains('fp-port')) { + location.href = `/device/${encodeURIComponent(el.dataset.switch)}#port-${encodeURIComponent(el.dataset.portIdx)}`; + } else if (el.dataset.cable) { + location.href = `/cutsheet#cable-${encodeURIComponent(el.dataset.cable)}`; + } +} + +const rackEl = document.getElementById('rack'); +rackEl.addEventListener('mouseover', onEnter); +rackEl.addEventListener('mouseout', onLeave); +rackEl.addEventListener('focusin', onEnter); +rackEl.addEventListener('focusout', onLeave); +rackEl.addEventListener('click', e => { + const el = portAt(e.target); + if (el) openPort(el); +}); +rackEl.addEventListener('keydown', e => { + const el = portAt(e.target); + if (el && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + openPort(el); + } +}); + +async function load() { + try { + render(await lt.api.get('/api/rack')); + } catch (err) { + if (_rack) return; // keep the last drawing on a failed refresh + document.getElementById('rack').innerHTML = `
+
Could not load the rack
+
${esc(err.message || err)}
`; + } +} + +readout(null); +load(); +setInterval(() => { if (!document.hidden && !document.querySelector('#rack .rack-hl')) load(); }, 60000); diff --git a/static/style.css b/static/style.css index ad0364f..8353750 100644 --- a/static/style.css +++ b/static/style.css @@ -1431,3 +1431,58 @@ polygon.fp-led-off { fill: #6b6c71; } body.fp-marks-off .fp-svg .fp-mark { display: none; } .fp-mark-toggle { cursor: pointer; gap: 6px; } .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; } +.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; + 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); } +.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; } +.rack-item .fp-svg { width: 100%; height: 100%; } +.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); } + +.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-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-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; } +.rack-key-ok { background: var(--accent-green); } +.rack-key-bad { background: var(--status-critical, #e0312b); } +.rack-key-doc { background: #9aa7b4; } +.rack-warnings { border: 1px solid var(--status-critical, #e0312b); padding: 8px 14px; margin-bottom: 14px; font-size: .85rem; } +.rack-warnings ul { margin: 0 0 6px; padding-left: 18px; } +.rack-warnings p { margin: 0; } +.rack-unplaced { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.rack-empty { margin: 12px; } +@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; } +} diff --git a/templates/base.html b/templates/base.html index 7977400..9d66193 100644 --- a/templates/base.html +++ b/templates/base.html @@ -43,6 +43,9 @@ Cutsheet + Rack {% if user.groups and 'admin' in user.groups %} Cutsheet + + Rack + {% if user.groups and 'admin' in user.groups %} Rack
+

Open the rack drawing →

diff --git a/templates/rack.html b/templates/rack.html new file mode 100644 index 0000000..31ca46b --- /dev/null +++ b/templates/rack.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Rack{% endblock %} + +{% block content %} +
+
+

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 %} + + {% endif %} +
+ + + +
+
+

Front

+ 0 +
+

+ +
+

+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index 2c9980f..eb886ce 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -427,3 +427,44 @@ class TestInventoryLinks: gandalf._cutsheet_schema_ready = True resp = client.put('/api/device/USW Flex/inventory', data='url=x', headers=AUTH) assert resp.status_code == 400 + + +class TestRackApi: + def test_rack_combines_items_switches_and_marks(self, client): + gandalf._cutsheet_schema_ready = True + db = gandalf.db + db.cutsheet_load.return_value = { + 'items': [ + {'id': 1, 'name': 'USW Flex', 'kind': 'device', 'port_count': None, 'rack_u': 20, 'rack_height': 1, + 'location': '', 'notes': ''}, + {'id': 2, 'name': 'PP1', 'kind': 'patch_panel', 'port_count': 24, 'rack_u': 21, 'rack_height': 1, + 'location': '', 'notes': ''}, + {'id': 3, 'name': 'Shelf', 'kind': 'shelf', 'port_count': None, 'rack_u': 20, 'rack_height': 1, + 'location': '', 'notes': ''}, + {'id': 4, 'name': 'Loose', 'kind': 'other', 'port_count': None, 'rack_u': None, 'rack_height': None, + 'location': '', 'notes': ''}, + ], + 'cables': [{'id': 7, 'label': 'C07', 'a_device': 'USW Flex', 'a_port': '2', 'a_side': '', + 'b_device': 'PP1', 'b_port': '5', 'b_side': 'front', 'status': 'installed', + 'cable_type': '', 'length_m': None, 'color': '', 'notes': '', 'label_applied': False}], + 'port_labels': [{'item_id': 2, 'item_name': 'PP1', 'port': '5', 'label': 'OFFICE', 'applied': False}], + } + data = client.get('/api/rack', headers=AUTH).get_json() + assert data['units'] == 42 and data['unplaced'] == ['Loose'] + assert [i['name'] for i in data['items']] == ['PP1', 'USW Flex'] + assert data['overlaps'] == [{'name': 'Shelf', 'units': 'U20', 'with': ['USW Flex']}] + 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['ports']['2']['mark'] in ('ok', 'bad', 'doc') + + def test_rack_page_and_auth(self, client): + assert 'rack.js' in client.get('/rack', headers=AUTH).get_data(as_text=True) + assert client.get('/api/rack').status_code == 401 + + def test_units_from_config(self, client): + gandalf._cutsheet_schema_ready = True + 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 diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index c5d6c01..96ea8cf 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -248,3 +248,51 @@ class TestProblems: cables = [cable(1, ('sw', '13'), ('tv', 'eth0')), cable(2, ('sw', '13'), ('pc', 'eth0'))] found = cs.problems(cables, ITEMS, switch([port(13, seen='tv')])) assert sorted(p['cable_id'] for p in found) == [1, 2] + + +class TestRackLayout: + _ids = {} + + def _item(self, name, u, h=None): + # IDs in creation order: on a tie for the same top unit, the earlier record is drawn + return {'id': self._ids.setdefault(name, len(self._ids) + 1), 'name': name, 'kind': 'device', + 'rack_u': u, 'rack_height': h} + + def test_top_first_with_top_unit(self): + out = cs.rack_layout([self._item('a', 1), self._item('b', 10, 2), {'name': 'loose', 'rack_u': None}], 42) + assert [(i['name'], i['rack_top']) for i in out['placed']] == [('b', 11), ('a', 1)] + assert out['overlaps'] == [] and out['outside'] == [] + + def test_overlap_is_reported_not_drawn(self): + out = cs.rack_layout([self._item('ups', 1, 2), self._item('shelf', 2)], 42) + assert [i['name'] for i in out['placed']] == ['ups'] + assert out['overlaps'] == [{'name': 'shelf', 'units': 'U2', 'with': ['ups']}] + + def test_past_the_top(self): + out = cs.rack_layout([self._item('tall', 41, 3)], 42) + assert out['placed'] == [] and out['outside'] == [{'name': 'tall', 'units': 'U41–43'}] + + +class TestPortMarks: + def test_worst_cable_wins_and_runs_merge(self): + cables = [ + dict(cable(1, ('sw', '3'), ('PP1', '1', 'front'), label='C1'), verdict='match', detail='ok', run=[1, 2]), + dict(cable(2, ('PP1', '1', 'rear'), ('jack', '1')), verdict='wrong_neighbor', detail='bad', run=[1, 2]), + dict(cable(3, ('sw', '4'), ('x',)), verdict='planned', detail='Planned', run=[3]), + dict(cable(4, ('sw', '5'), ('y',), status='retired'), verdict='retired', detail='', run=[4]), + ] + marks = cs.port_marks(cables) + assert marks[('sw', '3')]['mark'] == 'ok' + assert marks[('PP1', '1')]['mark'] == 'bad' and marks[('PP1', '1')]['cable_id'] == 2 + assert marks[('PP1', '1')]['run'] == [1, 2] + assert marks[('PP1', '1')]['text'] == 'cable C1: ok; cable #2: bad' + assert marks[('sw', '4')]['text'] == 'cable #3: Planned' + assert marks[('sw', '4')]['mark'] == 'doc' + assert ('sw', '5') not in marks and ('x', '') not in marks + + def test_shared_check_is_said_once(self): + cables = [ + dict(cable(1, ('sw', '3'), ('PP1', '1', 'front'), label='C1'), verdict='idle', detail='Idle', run=[1, 2]), + 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'