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'