Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 50s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 50s
Test / Python Tests (pytest) (push) Successful in 1m2s
Lint / Python (flake8) (pull_request) Successful in 52s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m42s
Test / Python Tests (pytest) (pull_request) Successful in 2m2s
The rack front is drawn from the rack units on cutsheet items. Switches with a faceplate use it, with live port state; patch panels get a generic panel with port labels; everything else is a labelled plate. Each documented port shows its cutsheet state, and hovering a port lights up every port on the same cable run (through patch panels) and names the cables. Clicking opens the switch port or the cable. Overlapping items and items past the top of the rack are listed instead of drawn over another; where two claim the same top unit, the first one recorded wins. rack.units sets the height (default 42). /api/rack builds on the cutsheet view; cutsheet.rack_layout and cutsheet.port_marks hold the placement and per-port logic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
9.4 KiB
JavaScript
245 lines
9.4 KiB
JavaScript
'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);
|