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);
|