'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 = `
Cables
ID From To Type Status Check ${CS_CAN_EDIT ? ' ' : ''}
${rows.map(c => `
${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)}
${CS_CAN_EDIT ? `Edit ` : ''}
`).join('')}
`;
}
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 = `
Undocumented links
Switch port UniFi name Sees Found by ${CS_CAN_EDIT ? ' ' : ''}
${list.slice(0, _showAllSuggestions ? list.length : SUGGESTIONS_SHOWN).map((s, i) => `
${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')}
${CS_CAN_EDIT ? `Add ` : ''}
`).join('')}
${list.length > SUGGESTIONS_SHOWN ? `
${_showAllSuggestions ? 'Show fewer' : `Show all ${list.length}`} ` : ''}`;
}
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 ? `edit ` : ''}
${cell(front, 'front')}
${cell(rear, 'rear')}
`);
}
return `
╚ ╝
Ports on ${esc(p.name)}
Port Label Front (patch cord) Rear (run)
${rows.join('')}
`;
}).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 = `
Inventory
Name Kind Ports Rack Location Notes ${CS_CAN_EDIT ? ' ' : ''}
${items.map(i => `
${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 || '')}
${CS_CAN_EDIT ? `Edit ` : ''}
`).join('')}
`;
}
function renderHistory() {
const rows = _cs.history || [];
const wrap = document.getElementById('cs-history');
if (!rows.length) {
wrap.innerHTML = emptyState('📋', 'No changes yet', '');
return;
}
wrap.innerHTML = `
Change history
When Who Change
${rows.map(h => `
${esc(fmtWhen(h.changed_at))} ${esc(h.changed_by)} ${esc(h.summary)} `).join('')}
`;
}
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 => `${esc(d.kind)} `).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 => `${esc(p.text)} `).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 => `${esc(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 ? `${data.errors.map(x => `${esc(x)} `).join('')} ` : '');
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);