'use strict'; /* global CS_CAN_EDIT, deviceUrl */ // Cutsheet page: renders /api/cutsheet and edits cables, items and panel-port labels. const esc = s => lt.escHtml(s == null ? '' : String(s)); const VERDICT_CLASS = { match: 'badge-ok', wrong_neighbor: 'badge-critical', no_link: 'badge-critical', missing_port: 'badge-critical', conflict: 'badge-critical', unverified: 'badge-info', idle: 'badge-neutral', not_checked: 'badge-neutral', planned: 'badge-purple', dark: 'badge-neutral', retired: 'badge-neutral', }; const VERDICT_SHORT = { match: 'Matches', wrong_neighbor: 'Wrong neighbor', no_link: 'No link', missing_port: 'Missing port', conflict: 'Port conflict', unverified: 'Unverified', idle: 'Idle', not_checked: 'Not checked', planned: 'Planned', dark: 'Dark', retired: 'Retired', }; const KIND_TEXT = { patch_panel: 'Patch panel', wall_jack: 'Wall jack', device: 'Device', shelf: 'Shelf', other: 'Other' }; let _cs = null; let _showAllSuggestions = false; const SUGGESTIONS_SHOWN = 10; function natural(a, b) { return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); } function itemByName(name) { return (_cs.items || []).find(i => i.name === name); } function isPassive(name) { const item = itemByName(name); return !!(item && _cs.options.passive_kinds.includes(item.kind)); } function verdictBadge(c) { return `${esc(VERDICT_SHORT[c.verdict] || c.verdict)}`; } function deviceLink(name) { if (!name) return 'unterminated'; const item = itemByName(name); const known = (_cs.catalog || []).some(d => d.name === name && (d.kind === 'switch' || d.kind === 'host')); if (item && !known) return `${esc(name)}`; return `${esc(name)}`; } function endText(c, end) { const device = c[`${end}_device`]; if (!device) return deviceLink(''); const port = c[`${end}_port`]; const side = c[`${end}_side`]; let text = deviceLink(device); if (port) text += ` ${/^\d+$/.test(port) ? 'port ' : ''}${esc(port)}`; if (side) text += ` ${esc(side)}`; return text; } function pathText(c) { // The run beyond this cable, through any panels or jacks const nodes = (c.path || []).filter(h => !('cable_id' in h)).map(h => h.text); return nodes.length > 2 ? nodes.map(esc).join(' → ') : ''; } // ── Rendering ─────────────────────────────────────────────────────────── function renderSummary() { const cables = _cs.cables.filter(c => c.status !== 'retired'); const problems = cables.filter(c => c.problem).length; const matches = cables.filter(c => c.verdict === 'match').length; const labels = _cs.labels.cables.length + _cs.labels.ports.length; const chip = (n, text, cls, target) => `${n} ${esc(text)}`; document.getElementById('cs-summary').innerHTML = [ chip(cables.length, 'cables', '', 'cs-cables-section'), chip(matches, 'match what Gandalf sees', 'cs-chip-ok', 'cs-cables-section'), chip(problems, problems === 1 ? 'problem' : 'problems', problems ? 'cs-chip-bad' : '', 'cs-cables-section'), chip(_cs.suggestions.length, 'undocumented links', _cs.suggestions.length ? 'cs-chip-warn' : '', 'cs-suggest-section'), chip(labels, 'labels to make', labels ? 'cs-chip-warn' : '', 'cs-labels-section'), ].join(''); } function cableMatches(c, words, status, problemsOnly) { if (status && c.status !== status) return false; if (problemsOnly && !c.problem) return false; if (!words.length) return true; const hay = [c.label, c.a_text, c.b_text, c.cable_type, c.color, c.notes, c.detail, c.status] .join(' ').toLowerCase(); return words.every(w => hay.includes(w)); } function renderCables() { const words = document.getElementById('cs-filter').value.trim().toLowerCase().split(/\s+/).filter(Boolean); const status = document.getElementById('cs-status-filter').value; const problemsOnly = document.getElementById('cs-problems-only').checked; const rows = _cs.cables .filter(c => cableMatches(c, words, status, problemsOnly)) .sort((a, b) => (b.problem - a.problem) || natural(a.label || '~', b.label || '~') || a.id - b.id); document.getElementById('cs-cable-count').textContent = _cs.cables.length; const wrap = document.getElementById('cs-cables'); if (!_cs.cables.length) { wrap.innerHTML = emptyState('🔌', 'No cables yet', CS_CAN_EDIT ? 'Add one with + Cable, or start from the undocumented links below.' : 'An admin can add cables.'); return; } if (!rows.length) { wrap.innerHTML = emptyState('🔍', 'No cables match the filter', ''); return; } wrap.innerHTML = `
${CS_CAN_EDIT ? '' : ''}${rows.map(c => ` ${CS_CAN_EDIT ? `` : ''} `).join('')}
Cables
IDFromToTypeStatusCheck
${c.label ? esc(c.label) : '–'}${c.label && !c.label_applied && c.status !== 'retired' ? ' not stuck on' : ''} ${endText(c, 'a')} ${endText(c, 'b')}${pathText(c) ? `
${pathText(c)}
` : ''}
${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '–'} ${esc(c.status)} ${verdictBadge(c)}
${esc(c.detail)}
`; } function renderSuggestions() { const list = _cs.suggestions; document.getElementById('cs-suggest-count').textContent = list.length; const wrap = document.getElementById('cs-suggestions'); if (!list.length) { wrap.innerHTML = emptyState('✅', 'Every live switch port is documented', ''); return; } wrap.innerHTML = `
${CS_CAN_EDIT ? '' : ''}${list.slice(0, _showAllSuggestions ? list.length : SUGGESTIONS_SHOWN).map((s, i) => ` ${CS_CAN_EDIT ? `` : ''} `).join('')}
Undocumented links
Switch portUniFi nameSeesFound by
${deviceLink(s.switch)} port ${esc(s.port_idx)} ${esc(s.port_name)} ${s.neighbor ? `${deviceLink(s.neighbor)}${s.neighbor_port ? ` ${esc(s.neighbor_port)}` : ''}` : 'unknown'} ${esc(s.via || 'link only')}
${list.length > SUGGESTIONS_SHOWN ? `` : ''}`; } function cableAt(name, port, side) { return _cs.cables.find(c => c.status !== 'retired' && ['a', 'b'].some(e => c[`${e}_device`] === name && c[`${e}_port`] === String(port) && c[`${e}_side`] === side)); } function farEnd(c, name, port, side) { const end = (c.a_device === name && c.a_port === String(port) && c.a_side === side) ? 'b' : 'a'; return endText(c, end); } function renderPanels() { const panels = _cs.items.filter(i => i.kind === 'patch_panel').sort((a, b) => natural(a.name, b.name)); const wrap = document.getElementById('cs-panels'); if (!panels.length) { wrap.innerHTML = emptyState('🗄️', 'No patch panels yet', CS_CAN_EDIT ? 'Add one with + Panel / item.' : ''); return; } const labels = {}; for (const l of _cs.port_labels) labels[`${l.item_id}|${l.port}`] = l; wrap.innerHTML = panels.map(p => { const rows = []; for (let n = 1; n <= (p.port_count || 0); n++) { const front = cableAt(p.name, n, 'front'); const rear = cableAt(p.name, n, 'rear'); const lab = labels[`${p.id}|${n}`]; const cell = (c, side) => c ? `${esc(c.label || 'unlabeled')} → ${farEnd(c, p.name, n, side)}` : '–'; rows.push(` ${n} ${lab ? esc(lab.label) + (lab.applied ? '' : ' not stuck on') : '–'} ${CS_CAN_EDIT ? `` : ''} ${cell(front, 'front')} ${cell(rear, 'rear')} `); } return `
╚╝
${esc(p.name)}${p.rack_u ? ` · U${esc(p.rack_u)}` : ''}${p.location ? ` · ${esc(p.location)}` : ''}
${rows.join('')}
Ports on ${esc(p.name)}
PortLabelFront (patch cord)Rear (run)
`; }).join(''); } function renderLabels() { const { cables, ports, unlabeled_cable_ids: unlabeled } = _cs.labels; document.getElementById('cs-label-count').textContent = cables.length + ports.length; const wrap = document.getElementById('cs-labels'); if (!cables.length && !ports.length) { wrap.innerHTML = emptyState('🏷️', 'Nothing to label', unlabeled.length ? `${unlabeled.length} cable(s) have no ID yet: give them one to add them here.` : ''); return; } const tick = (attrs, label) => CS_CAN_EDIT ? `` : ''; wrap.innerHTML = `
${cables.map(c => `
${tick(`data-action="applied-cable" data-id="${esc(c.id)}"`, `${c.text} is stuck on`)} ${esc(c.text)} ×${esc(c.copies)} ${esc(c.where)}
`).join('')} ${ports.map(p => `
${tick(`data-action="applied-port" data-item="${esc(p.item)}" data-port="${esc(p.port)}"`, `${p.text} is stuck on`)} ${esc(p.text)} ×1 ${esc(p.item)} port ${esc(p.port)}
`).join('')}
${unlabeled.length ? `

${unlabeled.length} more cable(s) have no ID yet.

` : ''}`; } function renderRack() { const placed = _cs.items.filter(i => i.rack_u).sort((a, b) => (b.rack_u + (b.rack_height || 1)) - (a.rack_u + (a.rack_height || 1))); const wrap = document.getElementById('cs-rack'); if (!placed.length) { wrap.innerHTML = emptyState('🗄️', 'No rack positions recorded', CS_CAN_EDIT ? 'Set a rack unit on a panel, shelf or device in the inventory.' : ''); return; } wrap.innerHTML = `
${placed.map(i => { const top = i.rack_u + (i.rack_height || 1) - 1; return `
${i.rack_height > 1 ? `U${esc(top)}–${esc(i.rack_u)}` : `U${esc(i.rack_u)}`} ${deviceLink(i.name)} ${esc(KIND_TEXT[i.kind] || i.kind)}
`; }).join('')}
`; } function renderItems() { const items = [..._cs.items].sort((a, b) => natural(a.kind, b.kind) || natural(a.name, b.name)); document.getElementById('cs-item-count').textContent = items.length; const wrap = document.getElementById('cs-items'); if (!items.length) { wrap.innerHTML = emptyState('📦', 'No items yet', CS_CAN_EDIT ? 'Add your patch panel first: + Panel / item.' : ''); return; } wrap.innerHTML = `
${CS_CAN_EDIT ? '' : ''}${items.map(i => ` ${CS_CAN_EDIT ? `` : ''} `).join('')}
Inventory
NameKindPortsRackLocationNotes
${deviceLink(i.name)} ${esc(KIND_TEXT[i.kind] || i.kind)} ${esc(i.port_count || '–')} ${i.rack_u ? `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}` : '–'} ${esc(i.location || '–')} ${esc(i.notes || '')}
`; } function renderHistory() { const rows = _cs.history || []; const wrap = document.getElementById('cs-history'); if (!rows.length) { wrap.innerHTML = emptyState('📋', 'No changes yet', ''); return; } wrap.innerHTML = `
${rows.map(h => ` `).join('')}
Change history
WhenWhoChange
${esc(fmtWhen(h.changed_at))}${esc(h.changed_by)}${esc(h.summary)}
`; } function fmtWhen(iso) { const d = new Date(iso); return isNaN(d) ? iso : d.toLocaleString(); } function emptyState(icon, title, body) { return `
${icon}
${esc(title)}
${body ? `
${esc(body)}
` : ''}
`; } function renderDatalists() { document.getElementById('cs-device-list').innerHTML = (_cs.catalog || []).map(d => ``).join(''); } function render() { renderSummary(); renderCables(); renderSuggestions(); renderPanels(); renderLabels(); renderRack(); renderItems(); renderHistory(); renderDatalists(); } async function load() { try { _cs = await lt.api.get('/api/cutsheet'); } catch (err) { document.getElementById('cs-summary').innerHTML = `
Couldn't load the cutsheet: ${esc(err.message)}
`; return; } render(); const hash = window.location.hash; if (hash && /^#(cable|item)-\d+$/.test(hash)) { const el = document.getElementById(hash.slice(1)); if (el) { el.classList.add('cs-flash'); el.scrollIntoView({ block: 'center' }); } } } // ── Editing ───────────────────────────────────────────────────────────── function fillPorts(end) { const device = document.querySelector(`#cs-cable-form [name="${end}_device"]`).value; const entry = (_cs.catalog || []).find(d => d.name === device); document.getElementById(`cs-${end}-port-list`).innerHTML = entry ? entry.ports.map(p => ``).join('') : ''; const side = document.querySelector(`.cs-side[data-end="${end}"]`); side.hidden = !isPassive(device); } function openCable(cable, prefill) { const form = document.getElementById('cs-cable-form'); form.reset(); const types = ['', ..._cs.options.cable_types]; form.cable_type.innerHTML = types.map(t => ``).join(''); const data = Object.assign({ status: 'installed', label: cable ? '' : _cs.next_label }, cable || {}, prefill || {}); for (const key of ['id', 'label', 'a_device', 'a_port', 'b_device', 'b_port', 'cable_type', 'length_m', 'color', 'status', 'notes']) { if (form[key]) form[key].value = data[key] == null ? '' : data[key]; } form.a_side.value = data.a_side || 'front'; form.b_side.value = data.b_side || 'front'; form.label_applied.checked = !!data.label_applied; form.querySelector('.cs-delete').hidden = !cable; document.getElementById('cs-next-label').textContent = _cs.next_label; document.getElementById('cs-cable-title').textContent = cable ? `Edit ${cable.label || 'cable'}` : 'New cable'; fillPorts('a'); fillPorts('b'); lt.modal.open('cs-cable-modal'); } function openItem(item) { const form = document.getElementById('cs-item-form'); form.reset(); const data = Object.assign({ kind: 'patch_panel' }, item || {}); for (const key of ['id', 'name', 'kind', 'port_count', 'rack_u', 'rack_height', 'location', 'notes']) { form[key].value = data[key] == null ? '' : data[key]; } if (!item) form.port_count.value = 24; form.querySelector('.cs-delete').hidden = !item; document.getElementById('cs-item-title').textContent = item ? `Edit ${item.name}` : 'New panel or item'; lt.modal.open('cs-item-modal'); } function openPortLabel(itemId, port) { const form = document.getElementById('cs-port-form'); form.reset(); const item = _cs.items.find(i => String(i.id) === String(itemId)); const lab = _cs.port_labels.find(l => String(l.item_id) === String(itemId) && String(l.port) === String(port)); form.item_id.value = itemId; form.port.value = port; form.label.value = lab ? lab.label : ''; form.applied.checked = !!(lab && lab.applied); document.getElementById('cs-port-title').textContent = `${item ? item.name : 'Panel'} port ${port}`; lt.modal.open('cs-port-modal'); } function formData(form, fields) { const out = {}; for (const f of fields) out[f] = form[f].value; return out; } async function save(fn, okText, modalId) { try { await fn(); if (modalId) lt.modal.close(modalId); lt.toast.success(okText); await load(); } catch (err) { lt.toast.error(err.message || 'Save failed'); } } async function saveCable(e) { e.preventDefault(); const form = e.target; const body = formData(form, ['label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side', 'cable_type', 'length_m', 'color', 'status', 'notes']); body.label_applied = form.label_applied.checked; const id = form.id.value; await save(() => id ? lt.api.put(`/api/cutsheet/cables/${encodeURIComponent(id)}`, body) : lt.api.post('/api/cutsheet/cables', body), 'Cable saved', 'cs-cable-modal'); } async function saveItem(e) { e.preventDefault(); const form = e.target; const body = formData(form, ['name', 'kind', 'port_count', 'rack_u', 'rack_height', 'location', 'notes']); const id = form.id.value; await save(() => id ? lt.api.put(`/api/cutsheet/items/${encodeURIComponent(id)}`, body) : lt.api.post('/api/cutsheet/items', body), 'Saved', 'cs-item-modal'); } async function savePortLabel(e) { e.preventDefault(); const form = e.target; const url = `/api/cutsheet/items/${encodeURIComponent(form.item_id.value)}/ports/${encodeURIComponent(form.port.value)}`; await save(() => lt.api.put(url, { label: form.label.value, applied: form.applied.checked }), 'Label saved', 'cs-port-modal'); } async function markCableApplied(id, applied) { const c = _cs.cables.find(x => String(x.id) === String(id)); if (!c) return; const body = {}; for (const k of ['label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side', 'cable_type', 'length_m', 'color', 'status', 'notes']) { body[k] = c[k] == null ? '' : c[k]; } body.label_applied = applied; await save(() => lt.api.put(`/api/cutsheet/cables/${encodeURIComponent(id)}`, body), `${c.label} marked as labeled`); } async function markPortApplied(itemName, port, applied) { const item = itemByName(itemName); const lab = item && _cs.port_labels.find(l => l.item_id === item.id && String(l.port) === String(port)); if (!lab) return; await save(() => lt.api.put(`/api/cutsheet/items/${encodeURIComponent(item.id)}/ports/${encodeURIComponent(port)}`, { label: lab.label, applied }), `${lab.label} marked as labeled`); } async function importCsv(e, dryRun) { if (e) e.preventDefault(); const errorsEl = document.getElementById('cs-import-errors'); errorsEl.innerHTML = ''; const fileInput = document.getElementById('cs-import-file'); let text = document.getElementById('cs-import-text').value; if (fileInput.files.length) text = await fileInput.files[0].text(); let resp, data; try { // fetch directly: lt.api keeps only the message, and the row errors matter here resp = await fetch('/api/cutsheet/import', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, lt.csrf.headers()), body: JSON.stringify({ csv: text, dry_run: dryRun }), }); data = await resp.json(); } catch (err) { errorsEl.innerHTML = `
Import failed: ${esc(err.message)}
`; return; } if (!resp.ok) { errorsEl.innerHTML = `
${esc(data.error || `HTTP ${resp.status}`)}
` + (data.errors ? `` : ''); return; } if (dryRun) { errorsEl.innerHTML = `
${esc(data.rows)} row(s) look valid. Nothing imported yet.
`; return; } lt.modal.close('cs-import-modal'); lt.toast.success(`Imported: ${data.added} added, ${data.updated} updated`); await load(); } // ── Wiring ────────────────────────────────────────────────────────────── document.addEventListener('click', e => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; if (action === 'new-cable') openCable(null); else if (action === 'new-item') openItem(null); else if (action === 'edit-cable') openCable(_cs.cables.find(c => String(c.id) === btn.dataset.id)); else if (action === 'edit-item') openItem(_cs.items.find(i => String(i.id) === btn.dataset.id)); else if (action === 'edit-port-label') openPortLabel(btn.dataset.item, btn.dataset.port); else if (action === 'print-labels') printLabels(); else if (action === 'use-next-label') document.getElementById('cs-label').value = _cs.next_label; else if (action === 'toggle-suggestions') { _showAllSuggestions = !_showAllSuggestions; renderSuggestions(); } else if (action === 'import-check') importCsv(null, true); else if (action === 'add-suggestion') { const s = _cs.suggestions[Number(btn.dataset.i)]; openCable(null, { a_device: s.switch, a_port: String(s.port_idx), b_device: s.neighbor, b_port: s.neighbor_port, cable_type: s.cable_type, }); } else if (action === 'delete-cable') { const form = document.getElementById('cs-cable-form'); if (!confirm('Delete this cable record? Retiring it keeps the history visible; deleting removes it.')) return; save(() => lt.api.delete(`/api/cutsheet/cables/${encodeURIComponent(form.id.value)}`), 'Cable deleted', 'cs-cable-modal'); } else if (action === 'delete-item') { const form = document.getElementById('cs-item-form'); if (!confirm(`Delete ${form.name.value}?`)) return; save(() => lt.api.delete(`/api/cutsheet/items/${encodeURIComponent(form.id.value)}`), 'Deleted', 'cs-item-modal'); } }); document.addEventListener('change', e => { const t = e.target; if (t.dataset.action === 'applied-cable') markCableApplied(t.dataset.id, t.checked); else if (t.dataset.action === 'applied-port') markPortApplied(t.dataset.item, t.dataset.port, t.checked); }); function printLabels() { document.body.classList.add('cs-print-labels'); window.print(); document.body.classList.remove('cs-print-labels'); } function openFromQuery() { const q = new URLSearchParams(window.location.search); if (!CS_CAN_EDIT || !q.get('a_device')) return; openCable(null, { a_device: q.get('a_device'), a_port: q.get('a_port') || '' }); history.replaceState(null, '', window.location.pathname); } document.getElementById('cs-filter').addEventListener('input', () => _cs && renderCables()); document.getElementById('cs-status-filter').addEventListener('change', () => _cs && renderCables()); document.getElementById('cs-problems-only').addEventListener('change', () => _cs && renderCables()); if (CS_CAN_EDIT) { document.getElementById('cs-cable-form').addEventListener('submit', saveCable); document.getElementById('cs-item-form').addEventListener('submit', saveItem); document.getElementById('cs-port-form').addEventListener('submit', savePortLabel); document.getElementById('cs-import-form').addEventListener('submit', e => importCsv(e, false)); for (const end of ['a', 'b']) { const input = document.querySelector(`#cs-cable-form [name="${end}_device"]`); input.addEventListener('input', () => fillPorts(end)); input.addEventListener('change', () => fillPorts(end)); } } load().then(openFromQuery);