'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.device, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, rack: true, 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 ` ${esc(item.name)} ${ports.join('')} `; } const STATUS_CLASS = { up: 'dot-up', down: 'dot-down', degraded: 'dot-degraded' }; function statusDot(status) { return status ? `` : ''; } function shelfHtml(item) { const contents = item.contents || []; const things = contents.map(c => ` ${statusDot(c.status)}${esc(c.name)} `).join(''); return `
${esc(item.name)}
${things || 'empty shelf'}
`; } function plateHtml(item) { const unlinked = item.kind === 'device' && !item.linked; const sub = [item.device !== item.name ? item.device : '', KIND_TEXT[item.kind] || item.kind, item.location, unlinked ? 'not linked to a Gandalf device' : ''].filter(Boolean).join(' · '); return `
${statusDot(item.status)}${esc(item.name)} ${esc(sub)}
`; } 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 if (item.kind === 'shelf') body = shelfHtml(item); else body = plateHtml(item); return `
${body}
`; } function uLabels(top, bottom) { const spans = []; for (let u = top; u >= bottom; u--) spans.push(`${u}`); return ``; } // Long runs of empty units are folded into one thin band unless the viewer asks for them const GAP_MIN = 3; function showEmpty() { try { return localStorage.getItem('gandalf.rackShowEmpty') === '1'; } catch (_) { return false; } } function gapRow(top, bottom) { const n = top - bottom + 1; return `
U${bottom}–${top} · ${n}U free
`; } function row(top, bottom, inner, cls) { const n = top - bottom + 1; return `
${uLabels(top, bottom)}
${inner}
`; } // ── Page ──────────────────────────────────────────────────────────────── function renderTabs(data) { const tabs = document.getElementById('rack-tabs'); tabs.hidden = data.racks.length < 2; tabs.innerHTML = data.racks.map(r => { const on = r.name === data.rack; return ``; }).join(''); document.getElementById('rack-title').textContent = data.racks.length > 1 ? `${data.rack} · front` : 'Front'; } function render(data) { _rack = data; renderTabs(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(!showEmpty() && u - low + 1 >= GAP_MIN ? gapRow(u, low) : row(u, low, '', 'rack-row-empty')); u = low - 1; } document.getElementById('rack').innerHTML = rows.join(''); // Every rack device is 19" wide: fill the slot rather than keep each drawing's own proportions document.querySelectorAll('#rack .fp-svg').forEach(s => s.setAttribute('preserveAspectRatio', 'none')); document.getElementById('rack-show-empty').checked = showEmpty(); 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.device === 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(`${esc(o.name)} (${esc(o.units)}) overlaps ${esc(o.with.join(', '))} and is not drawn.`)); data.outside.forEach(o => notes.push(`${esc(o.name)} (${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 ? `

Fix the rack unit on the cutsheet.

` : ''; const unplaced = document.getElementById('rack-unplaced'); unplaced.innerHTML = data.unplaced.length ? `Not in the drawing (no rack unit set): ${data.unplaced.map(n => `${esc(n)}`).join(' ')}` : ''; if (!data.items.length) { document.getElementById('rack').insertAdjacentHTML('afterbegin', `
Nothing placed yet
Give panels, shelves and devices a rack unit on the cutsheet and they appear here.
`); } } 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); } }); function currentRack() { return new URLSearchParams(location.search).get('rack') || ''; } document.getElementById('rack-tabs').addEventListener('click', e => { const tab = e.target.closest('[data-rack]'); if (!tab) return; const first = _rack && _rack.racks[0] && _rack.racks[0].name; history.replaceState(null, '', tab.dataset.rack === first ? location.pathname : `?rack=${encodeURIComponent(tab.dataset.rack)}`); _rack = null; load(); }); async function load() { try { const rack = currentRack(); render(await lt.api.get(`/api/rack${rack ? `?rack=${encodeURIComponent(rack)}` : ''}`)); } catch (err) { if (_rack) return; // keep the last drawing on a failed refresh document.getElementById('rack').innerHTML = `
Could not load the rack
${esc(err.message || err)}
`; } } readout(null); load(); setInterval(() => { if (!document.hidden && !document.querySelector('#rack .rack-hl')) load(); }, 60000); // ── Export (SVG / PNG) ────────────────────────────────────────────────── // Rebuilds the drawing as one standalone SVG on a white page, for printing or planning. // Faceplates and panels are copied from the page with their computed styles inlined, // since the stylesheet doesn't travel with the file. const SVG_NS = 'http://www.w3.org/2000/svg'; const STYLE_PROPS = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-size', 'font-family', 'font-weight', 'letter-spacing', 'text-anchor', 'dominant-baseline', 'display', 'visibility']; function inlineStyles(src, dst) { const cs = getComputedStyle(src); dst.setAttribute('style', STYLE_PROPS.map(p => `${p}:${cs.getPropertyValue(p)}`).join(';')); dst.removeAttribute('class'); for (let i = 0; i < src.children.length; i++) inlineStyles(src.children[i], dst.children[i]); } function svgEl(tag, attrs, text) { const el = document.createElementNS(SVG_NS, tag); Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v)); if (text != null) el.textContent = text; return el; } function buildExport() { document.querySelectorAll('#rack .rack-hl').forEach(el => el.classList.remove('rack-hl')); const W = 1100, gutter = 46, pad = 20, head = 44, unit = W / RATIO; const rows = [...document.querySelectorAll('#rack .rack-row')]; const height = rows.reduce((h, r) => h + (r.dataset.gap ? 26 : parseInt(r.style.getPropertyValue('--n'), 10) * unit), 0); const H = head + height + pad * 2; const root = svgEl('svg', { xmlns: SVG_NS, width: W + gutter + pad * 2, height: H, viewBox: `0 0 ${W + gutter + pad * 2} ${H}`, 'font-family': 'Helvetica, Arial, sans-serif' }); root.appendChild(svgEl('rect', { width: '100%', height: '100%', fill: '#ffffff' })); const stamp = new Date().toLocaleString(); root.appendChild(svgEl('text', { x: pad, y: pad + 14, 'font-size': 18, 'font-weight': 700, fill: '#111' }, `${_rack.rack} · front`)); root.appendChild(svgEl('text', { x: W + gutter + pad, y: pad + 14, 'font-size': 11, fill: '#666', 'text-anchor': 'end' }, `Gandalf · ${stamp}`)); let y = head + pad; const x = pad + gutter; rows.forEach(row => { const n = parseInt(row.style.getPropertyValue('--n'), 10); if (row.dataset.gap) { const band = 26; root.appendChild(svgEl('rect', { x, y: y + 2, width: W, height: band - 4, fill: '#f4f5f7', stroke: '#d5d9de', 'stroke-dasharray': '4 4' })); root.appendChild(svgEl('text', { x: x + W / 2, y: y + band / 2 + 4, 'font-size': 11, fill: '#777', 'text-anchor': 'middle' }, row.textContent.trim())); y += band; return; } const h = n * unit; [...row.querySelectorAll('.rack-u span')].forEach((s, i) => { root.appendChild(svgEl('text', { x: x - 8, y: y + unit * i + unit / 2 + 4, 'font-size': 11, fill: '#777', 'text-anchor': 'end' }, s.textContent)); }); const art = row.querySelector('svg.fp-svg, svg.rack-svg'); const plate = row.querySelector('.rack-plate'); if (art) { const clone = art.cloneNode(true); inlineStyles(art, clone); ['x', 'y', 'width', 'height'].forEach((k, i) => clone.setAttribute(k, [x, y, W, h][i])); clone.setAttribute('preserveAspectRatio', art.classList.contains('fp-svg') ? 'none' : 'xMidYMid meet'); clone.removeAttribute('style'); root.appendChild(clone); } else if (plate) { root.appendChild(svgEl('rect', { x, y: y + 1, width: W, height: h - 2, rx: 3, fill: '#e9ecef', stroke: '#9aa3ad' })); const name = plate.querySelector('.rack-plate-name, .rack-shelf-label'); const things = [...plate.querySelectorAll('.rack-shelf-name')].map(t => t.textContent); const sub = things.length ? things.join(' · ') : (plate.querySelector('.rack-plate-sub') || {}).textContent || ''; root.appendChild(svgEl('text', { x: x + 16, y: y + h / 2 + (sub ? -2 : 5), 'font-size': 15, 'font-weight': 700, fill: '#111' }, name ? name.textContent : '')); if (sub) root.appendChild(svgEl('text', { x: x + 16, y: y + h / 2 + 15, 'font-size': 11, fill: '#444' }, sub)); } else { root.appendChild(svgEl('rect', { x, y: y + 1, width: W, height: h - 2, fill: 'none', stroke: '#d5d9de', 'stroke-dasharray': '4 4' })); } y += h; }); return new XMLSerializer().serializeToString(root); } function download(blob, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 10000); } async function exportRack(kind) { if (!_rack || !_rack.items.length) { lt.toast.error('Nothing in this rack to export yet'); return; } const svg = buildExport(); const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-rack`; if (kind === 'svg') { download(new Blob([svg], { type: 'image/svg+xml' }), `${base}.svg`); return; } const img = new Image(); const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })); try { await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; }); const scale = 2; const canvas = document.createElement('canvas'); canvas.width = img.width * scale; canvas.height = img.height * scale; const ctx = canvas.getContext('2d'); ctx.scale(scale, scale); ctx.drawImage(img, 0, 0); canvas.toBlob(blob => download(blob, `${base}.png`), 'image/png'); } catch (err) { lt.toast.error('Could not render the PNG; try the SVG'); } finally { URL.revokeObjectURL(url); } } document.getElementById('rack-show-empty').addEventListener('change', e => { try { localStorage.setItem('gandalf.rackShowEmpty', e.target.checked ? '1' : '0'); } catch (_) { /* private mode */ } if (_rack) render(_rack); }); document.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', () => exportRack(btn.dataset.export)));