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
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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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'; } },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user