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 ``;
+}
+
+function plateHtml(item) {
+ const sub = [KIND_TEXT[item.kind] || item.kind, item.location].filter(Boolean).join(' · ');
+ return `
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.