Merge pull request 'Rack drawing (/rack)' (#23) from rack-view into main
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (flake8) (push) Successful in 1m14s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m41s
Lint / Deploy (push) Successful in 6s
Test / Python Tests (pytest) (push) Successful in 47s

This commit was merged in pull request #23.
This commit is contained in:
2026-11-10 21:33:29 -05:00
10 changed files with 574 additions and 0 deletions
+16
View File
@@ -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
```
+60
View File
@@ -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
+62
View File
@@ -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
+244
View File
@@ -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(`<g class="rack-pp-port${m ? ` rack-pp-${m.mark}` : ''}"${attrs} transform="translate(${x} ${y})">
<title>${esc(title)}</title>
<text class="rack-pp-num" y="-17" text-anchor="middle">${n}</text>
<rect class="rack-pp-jack" x="-12" y="-10" width="24" height="20" rx="2"/>
<rect class="rack-pp-hit" x="${-pitch / 2}" y="-28" width="${pitch}" height="56"/>
${label ? `<text class="rack-pp-label" y="24" text-anchor="middle">${esc(short(label))}</text>` : ''}
</g>`);
}
return `<svg class="rack-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="group"
aria-label="${esc(item.name)} patch panel">
<rect class="rack-pp-body" width="${W}" height="${H}" rx="3"/>
<text class="rack-pp-name" x="${ear - 50}" y="${H / 2 + 5}">${esc(item.name)}</text>
${ports.join('')}
</svg>`;
}
function plateHtml(item) {
const sub = [KIND_TEXT[item.kind] || item.kind, item.location].filter(Boolean).join(' · ');
return `<div class="rack-plate">
<a class="device-link rack-plate-name" href="${esc(item.url)}">${esc(item.name)}</a>
<span class="rack-plate-sub">${esc(sub)}</span>
</div>`;
}
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 `<div class="rack-item rack-kind-${esc(item.kind)}" data-item="${esc(item.id)}">${body}</div>`;
}
function uLabels(top, bottom) {
const spans = [];
for (let u = top; u >= bottom; u--) spans.push(`<span>${u}</span>`);
return `<div class="rack-u" aria-hidden="true">${spans.join('')}</div>`;
}
function row(top, bottom, inner, cls) {
const n = top - bottom + 1;
return `<div class="rack-row ${cls}" style="--n:${n}">
${uLabels(top, bottom)}
<div class="rack-slot" style="aspect-ratio:${RATIO} / ${n}">${inner}</div>
</div>`;
}
// ── 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(`<strong>${esc(o.name)}</strong> (${esc(o.units)}) overlaps ${esc(o.with.join(', '))} and is not drawn.`));
data.outside.forEach(o => notes.push(`<strong>${esc(o.name)}</strong> (${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 ? `<ul>${notes.map(n => `<li>${n}</li>`).join('')}</ul>
<p>Fix the rack unit on the <a href="/cutsheet#cs-items-section">cutsheet</a>.</p>` : '';
const unplaced = document.getElementById('rack-unplaced');
unplaced.innerHTML = data.unplaced.length
? `Not in the drawing (no rack unit set): ${data.unplaced.map(n => `<span class="cs-chip">${esc(n)}</span>`).join(' ')}`
: '';
if (!data.items.length) {
document.getElementById('rack').insertAdjacentHTML('afterbegin',
`<div class="lt-empty-state lt-empty-state--sm rack-empty"><div class="lt-empty-state-title">Nothing placed yet</div>
<div class="lt-empty-state-text">Give panels, shelves and devices a rack unit on the <a href="/cutsheet">cutsheet</a> and they appear here.</div></div>`);
}
}
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 = `<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-title">Could not load the rack</div>
<div class="lt-empty-state-text">${esc(err.message || err)}</div></div>`;
}
}
readout(null);
load();
setInterval(() => { if (!document.hidden && !document.querySelector('#rack .rack-hl')) load(); }, 60000);
+55
View File
@@ -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; }
}
+9
View File
@@ -43,6 +43,9 @@
<a href="{{ url_for('cutsheet_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'cutsheet_page' %} active{% endif %}"
{% if request.endpoint == 'cutsheet_page' %}aria-current="page"{% endif %}>Cutsheet</a>
<a href="{{ url_for('rack_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'rack_page' %} active{% endif %}"
{% if request.endpoint == 'rack_page' %}aria-current="page"{% endif %}>Rack</a>
{% if user.groups and 'admin' in user.groups %}
<a href="{{ url_for('suppressions_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
@@ -95,6 +98,11 @@
{% if request.endpoint == 'cutsheet_page' %}aria-current="page"{% endif %}>
Cutsheet
</a>
<a href="{{ url_for('rack_page') }}"
class="lt-nav-link{% if request.endpoint == 'rack_page' %} active{% endif %}"
{% if request.endpoint == 'rack_page' %}aria-current="page"{% endif %}>
Rack
</a>
{% if user.groups and 'admin' in user.groups %}
<a href="{{ url_for('suppressions_page') }}"
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
@@ -334,6 +342,7 @@
{ id: 'nav-dashboard', group: 'Navigate', icon: '~', label: 'Dashboard', action: function() { window.location.href = '/'; } },
{ id: 'nav-history', group: 'Navigate', icon: '⏱', label: 'Alert History', action: function() { window.location.href = '/events'; } },
{ id: 'nav-cutsheet', group: 'Navigate', icon: '🔌', label: 'Cutsheet', action: function() { window.location.href = '/cutsheet'; } },
{ id: 'nav-rack', group: 'Navigate', icon: '🗄', label: 'Rack', action: function() { window.location.href = '/rack'; } },
{ id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } },
{ id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
{ id: 'action-suppress', group: 'Actions', icon: '🔕', label: 'New Suppression', kbd: 'S', action: function() { if (typeof openSuppressModal === 'function') openSuppressModal('host','',''); else window.location.href='/suppressions'; } },
+1
View File
@@ -95,6 +95,7 @@
<h2 class="g-section-title">Rack</h2>
</div>
<div id="cs-rack"></div>
<p class="g-section-more"><a href="/rack">Open the rack drawing →</a></p>
</section>
<section class="g-section" id="cs-items-section">
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Rack{% endblock %}
{% block content %}
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Rack</h1>
<p class="g-page-sub">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.</p>
</div>
{% if can_edit %}
<div class="cs-actions no-print">
<a class="lt-btn lt-btn-secondary lt-btn-sm" href="/cutsheet#cs-items-section">Edit positions</a>
</div>
{% endif %}
</div>
<div class="rack-warnings" id="rack-warnings" role="alert" hidden></div>
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Front</h2>
<span class="g-section-badge" id="rack-count">0</span>
</div>
<p class="rack-readout" id="rack-readout" aria-live="polite"></p>
<div class="rack-legend" aria-hidden="true">
<span><i class="rack-key rack-key-ok"></i>matches cutsheet</span>
<span><i class="rack-key rack-key-bad"></i>mismatch</span>
<span><i class="rack-key rack-key-doc"></i>documented, unconfirmed</span>
</div>
<div class="rack" id="rack"><div class="link-loading">Loading rack</div></div>
<p class="cs-hint rack-unplaced" id="rack-unplaced"></p>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
<script src="{{ url_for('static', filename='rack.js') }}"></script>
{% endblock %}
+41
View File
@@ -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
+48
View File
@@ -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'