Files
gandalf/static/cutsheet.js
T
jaredandClaude Opus 5.5 86aa29b378
Lint / Python (flake8) (push) Successful in 1m8s
Security / Python Security (bandit) (push) Successful in 56s
Test / Python Tests (pytest) (push) Successful in 43s
Lint / Python (flake8) (pull_request) Successful in 50s
Lint / JS (eslint) (pull_request) Successful in 30s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 55s
Test / Python Tests (pytest) (pull_request) Successful in 50s
Lint / JS (eslint) (push) Successful in 16m17s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Cutsheet trace mode and mismatch alerts
Trace a cord: pick a panel port, press Start, and unplug the cord at the
panel. The page polls a new admin-only endpoint that reads every switch
port's state from UniFi (cached 2 s), shows the one port that dropped
with a button to record the cable, and confirms when it's plugged back
in. Several ports dropping at once, or none within 3 minutes, is
explained rather than guessed at.

Mismatch alerts: the monitor runs the cutsheet check every poll. Each
mismatched installed run (wrong neighbor, no link, missing port, port
conflict) keeps one cable_mismatch event on its switch, resolved when
fixed, and opens a P4 ticket after cutsheet.mismatch_ticket_hours
(default 24, 0 never). Suppressing the switch or port stops the ticket.

Also fixes buttons with the hidden attribute still showing, which left a
Delete button on the new-cable form.

Closes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:54:57 -05:00

706 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 `<span class="lt-badge ${VERDICT_CLASS[c.verdict] || 'badge-neutral'}" title="${esc(c.detail)}">${esc(VERDICT_SHORT[c.verdict] || c.verdict)}</span>`;
}
function deviceLink(name) {
if (!name) return '<span class="val-neutral">unterminated</span>';
const item = itemByName(name);
const known = (_cs.catalog || []).some(d => d.name === name && (d.kind === 'switch' || d.kind === 'host'));
if (item && !known) return `<a class="device-link" href="#item-${esc(item.id)}">${esc(name)}</a>`;
return `<a class="device-link" href="${esc(deviceUrl(name))}">${esc(name)}</a>`;
}
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 += ` <span class="cs-port">${/^\d+$/.test(port) ? 'port ' : ''}${esc(port)}</span>`;
if (side) text += ` <span class="cs-side-tag">${esc(side)}</span>`;
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) =>
`<a class="cs-chip ${cls}" href="#${target}"><b>${n}</b> ${esc(text)}</a>`;
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 = `
<div class="lt-table-wrap">
<table class="lt-table cs-table">
<caption class="lt-sr-only">Cables</caption>
<thead><tr><th>ID</th><th>From</th><th>To</th><th>Type</th><th>Status</th><th>Check</th>${CS_CAN_EDIT ? '<th class="no-print"></th>' : ''}</tr></thead>
<tbody>${rows.map(c => `
<tr id="cable-${esc(c.id)}" class="${c.problem ? 'cs-row-problem' : ''}${c.status === 'retired' ? ' row-resolved' : ''}">
<td class="cs-id">${c.label ? esc(c.label) : '<span class="val-neutral">–</span>'}${c.label && !c.label_applied && c.status !== 'retired' ? ' <span class="cs-tag" title="The label isn\'t on the cable yet">not stuck on</span>' : ''}</td>
<td data-label="From">${endText(c, 'a')}</td>
<td data-label="To">${endText(c, 'b')}${pathText(c) ? `<div class="cs-path">${pathText(c)}</div>` : ''}</td>
<td data-label="Type">${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '–'}</td>
<td data-label="Status">${esc(c.status)}</td>
<td data-label="Check">${verdictBadge(c)}<div class="cs-detail">${esc(c.detail)}</div></td>
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-cable" data-id="${esc(c.id)}" aria-label="Edit cable ${esc(c.label || c.id)}">Edit</button></td>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
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 = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Undocumented links</caption>
<thead><tr><th>Switch port</th><th>UniFi name</th><th>Sees</th><th>Found by</th>${CS_CAN_EDIT ? '<th></th>' : ''}</tr></thead>
<tbody>${list.slice(0, _showAllSuggestions ? list.length : SUGGESTIONS_SHOWN).map((s, i) => `
<tr>
<td>${deviceLink(s.switch)} <span class="cs-port">port ${esc(s.port_idx)}</span></td>
<td>${esc(s.port_name)}</td>
<td>${s.neighbor ? `${deviceLink(s.neighbor)}${s.neighbor_port ? ` <span class="cs-port">${esc(s.neighbor_port)}</span>` : ''}` : '<span class="val-neutral">unknown</span>'}</td>
<td>${esc(s.via || 'link only')}</td>
${CS_CAN_EDIT ? `<td><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="add-suggestion" data-i="${i}">Add</button></td>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>
${list.length > SUGGESTIONS_SHOWN ? `<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm cs-more" data-action="toggle-suggestions">
${_showAllSuggestions ? 'Show fewer' : `Show all ${list.length}`}</button>` : ''}`;
}
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
? `<a class="cs-id" href="#cable-${esc(c.id)}">${esc(c.label || 'unlabeled')}</a> → ${farEnd(c, p.name, n, side)}`
: '<span class="val-neutral">–</span>';
rows.push(`
<tr>
<td class="cs-id">${n}</td>
<td>${lab ? esc(lab.label) + (lab.applied ? '' : ' <span class="cs-tag">not stuck on</span>') : '<span class="val-neutral">–</span>'}
${CS_CAN_EDIT ? `<button type="button" class="cs-link-btn no-print" data-action="edit-port-label" data-item="${esc(p.id)}" data-port="${n}" aria-label="Edit label for ${esc(p.name)} port ${n}">edit</button>` : ''}</td>
<td>${cell(front, 'front')}</td>
<td>${cell(rear, 'rear')}</td>
</tr>`);
}
return `
<div class="lt-frame cs-panel" id="item-${esc(p.id)}">
<span class="lt-frame-bl">&#x255A;</span><span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">${esc(p.name)}${p.rack_u ? ` · U${esc(p.rack_u)}` : ''}${p.location ? ` · ${esc(p.location)}` : ''}</div>
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Ports on ${esc(p.name)}</caption>
<thead><tr><th>Port</th><th>Label</th><th>Front (patch cord)</th><th>Rear (run)</th></tr></thead>
<tbody>${rows.join('')}</tbody>
</table>
</div>
</div>`;
}).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
? `<input type="checkbox" class="no-print" ${attrs} aria-label="${esc(label)}">` : '';
wrap.innerHTML = `
<div class="cs-label-grid">
${cables.map(c => `
<div class="cs-label-card">
${tick(`data-action="applied-cable" data-id="${esc(c.id)}"`, `${c.text} is stuck on`)}
<span class="cs-label-text">${esc(c.text)}</span>
<span class="cs-label-copies">×${esc(c.copies)}</span>
<span class="cs-label-where">${esc(c.where)}</span>
</div>`).join('')}
${ports.map(p => `
<div class="cs-label-card">
${tick(`data-action="applied-port" data-item="${esc(p.item)}" data-port="${esc(p.port)}"`, `${p.text} is stuck on`)}
<span class="cs-label-text">${esc(p.text)}</span>
<span class="cs-label-copies">×1</span>
<span class="cs-label-where">${esc(p.item)} port ${esc(p.port)}</span>
</div>`).join('')}
</div>
${unlabeled.length ? `<p class="cs-hint">${unlabeled.length} more cable(s) have no ID yet.</p>` : ''}`;
}
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 = `
<div class="cs-rack">
${placed.map(i => {
const top = i.rack_u + (i.rack_height || 1) - 1;
return `
<div class="cs-rack-unit" style="--u:${Math.min(i.rack_height || 1, 8)}">
<span class="cs-rack-u">${i.rack_height > 1 ? `U${esc(top)}–${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}</span>
<span class="cs-rack-name">${deviceLink(i.name)}</span>
<span class="cs-rack-kind">${esc(KIND_TEXT[i.kind] || i.kind)}</span>
</div>`;
}).join('')}
</div>`;
}
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 = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Inventory</caption>
<thead><tr><th>Name</th><th>Kind</th><th>Ports</th><th>Rack</th><th>Location</th><th>Notes</th>${CS_CAN_EDIT ? '<th class="no-print"></th>' : ''}</tr></thead>
<tbody>${items.map(i => `
<tr id="${i.kind === 'patch_panel' ? '' : `item-${esc(i.id)}`}">
<td>${deviceLink(i.name)}</td>
<td>${esc(KIND_TEXT[i.kind] || i.kind)}</td>
<td>${esc(i.port_count || '–')}</td>
<td>${i.rack_u ? `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}` : '–'}</td>
<td>${esc(i.location || '–')}</td>
<td>${esc(i.notes || '')}</td>
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-item" data-id="${esc(i.id)}" aria-label="Edit ${esc(i.name)}">Edit</button></td>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function renderHistory() {
const rows = _cs.history || [];
const wrap = document.getElementById('cs-history');
if (!rows.length) {
wrap.innerHTML = emptyState('📋', 'No changes yet', '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Change history</caption>
<thead><tr><th>When</th><th>Who</th><th>Change</th></tr></thead>
<tbody>${rows.map(h => `
<tr><td class="ts-cell">${esc(fmtWhen(h.changed_at))}</td><td>${esc(h.changed_by)}</td><td>${esc(h.summary)}</td></tr>`).join('')}
</tbody>
</table>
</div>`;
}
function fmtWhen(iso) {
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString();
}
function emptyState(icon, title, body) {
return `<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">${icon}</div>
<div class="lt-empty-state-title">${esc(title)}</div>${body ? `<div class="lt-empty-state-body">${esc(body)}</div>` : ''}</div>`;
}
function renderDatalists() {
document.getElementById('cs-device-list').innerHTML =
(_cs.catalog || []).map(d => `<option value="${esc(d.name)}">${esc(d.kind)}</option>`).join('');
}
function render() {
renderSummary();
renderCables();
renderSuggestions();
renderPanels();
renderLabels();
renderRack();
renderItems();
renderHistory();
renderDatalists();
renderTracePanels();
}
async function load() {
try {
_cs = await lt.api.get('/api/cutsheet');
} catch (err) {
document.getElementById('cs-summary').innerHTML =
`<div class="lt-badge badge-critical">Couldn't load the cutsheet: ${esc(err.message)}</div>`;
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 => `<option value="${esc(p.value)}">${esc(p.text)}</option>`).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 => `<option value="${esc(t)}">${esc(t || '–')}</option>`).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 = `<div class="val-crit">Import failed: ${esc(err.message)}</div>`;
return;
}
if (!resp.ok) {
errorsEl.innerHTML = `<div class="val-crit">${esc(data.error || `HTTP ${resp.status}`)}</div>` +
(data.errors ? `<ul>${data.errors.map(x => `<li>${esc(x)}</li>`).join('')}</ul>` : '');
return;
}
if (dryRun) {
errorsEl.innerHTML = `<div class="val-good">${esc(data.rows)} row(s) look valid. Nothing imported yet.</div>`;
return;
}
lt.modal.close('cs-import-modal');
lt.toast.success(`Imported: ${data.added} added, ${data.updated} updated`);
await load();
}
// ── Trace mode ──────────────────────────────────────────────────────────
// Snapshot which switch ports are up, then watch for the one that drops when a
// cord is unplugged at the panel. UniFi takes a few seconds to notice.
const TRACE_POLL_MS = 3000;
const TRACE_TIMEOUT_MS = 180000;
let _trace = null;
function renderTracePanels() {
const select = document.getElementById('cs-trace-panel');
if (!select) return;
const panels = _cs.items.filter(i => i.kind === 'patch_panel').sort((a, b) => natural(a.name, b.name));
const keep = select.value;
select.innerHTML = panels.map(p => `<option value="${esc(p.name)}">${esc(p.name)}</option>`).join('');
if (panels.some(p => p.name === keep)) select.value = keep;
document.querySelector('[data-action="trace-start"]').disabled = !panels.length || !!_trace;
if (!panels.length) traceStatus('Add a patch panel first.');
}
function traceStatus(html) {
const el = document.getElementById('cs-trace-status');
if (el) el.innerHTML = html;
}
function portKey(sw, idx) { return JSON.stringify([sw, String(idx)]); }
function upPorts(switches) {
const up = new Set();
for (const [sw, ports] of Object.entries(switches || {})) {
for (const [idx, p] of Object.entries(ports)) if (p.up) up.add(portKey(sw, idx));
}
return up;
}
function traceButtons(running) {
document.querySelector('[data-action="trace-start"]').hidden = running;
document.querySelector('[data-action="trace-stop"]').hidden = !running;
}
function stopTrace() {
if (_trace && _trace.timer) clearTimeout(_trace.timer);
_trace = null;
traceButtons(false);
renderTracePanels();
}
async function startTrace() {
const panel = document.getElementById('cs-trace-panel').value;
const port = String(parseInt(document.getElementById('cs-trace-port').value, 10) || '');
const item = itemByName(panel);
if (!item || !port || Number(port) < 1 || Number(port) > (item.port_count || 0)) {
traceStatus(`<span class="val-crit">Choose a port between 1 and ${esc(item ? item.port_count : '?')}.</span>`);
return;
}
traceButtons(true);
traceStatus('Reading switch ports…');
let live;
try {
live = (await lt.api.get('/api/cutsheet/live-ports')).switches;
} catch (err) {
traceStatus(`<span class="val-crit">${esc(err.message)}</span>`);
traceButtons(false);
return;
}
_trace = { panel, port, baseline: upPorts(live), started: Date.now(), found: null, timer: null };
traceStatus(`Ready. <b>Unplug the cord from ${esc(panel)} port ${esc(port)} now.</b>
Watching ${_trace.baseline.size} linked switch ports… UniFi can take up to about 30 seconds to notice.`);
_trace.timer = setTimeout(pollTrace, TRACE_POLL_MS);
}
async function pollTrace() {
const t = _trace;
if (!t) return;
let live;
try {
live = (await lt.api.get('/api/cutsheet/live-ports')).switches;
} catch (err) {
if (_trace === t) t.timer = setTimeout(pollTrace, TRACE_POLL_MS);
return;
}
if (_trace !== t) return;
const up = upPorts(live);
if (t.found) {
if (up.has(t.found.key)) {
showTraceResult(t, true);
stopTrace();
} else {
t.timer = setTimeout(pollTrace, TRACE_POLL_MS);
}
return;
}
const dropped = [...t.baseline].filter(k => !up.has(k)).map(k => JSON.parse(k));
if (dropped.length === 1) {
const [sw, idx] = dropped[0];
t.found = { key: portKey(sw, idx), sw, idx, name: ((live[sw] || {})[idx] || {}).name || '' };
showTraceResult(t, false);
t.timer = setTimeout(pollTrace, TRACE_POLL_MS);
} else if (dropped.length > 1) {
traceStatus(`<span class="val-warn">Several ports dropped at once, so the cord can't be told apart:</span>
${dropped.map(([sw, idx]) => `${esc(sw)} port ${esc(idx)}`).join(', ')}. Plug everything back in and try again.`);
stopTrace();
} else if (Date.now() - t.started > TRACE_TIMEOUT_MS) {
traceStatus(`No switch port dropped in 3 minutes. If nothing is plugged in at the far end of
${esc(t.panel)} port ${esc(t.port)}, its switch port was never linked; trace that one by hand.`);
stopTrace();
} else {
t.timer = setTimeout(pollTrace, TRACE_POLL_MS);
}
}
function showTraceResult(t, replugged) {
const f = t.found;
const existing = cableAt(f.sw, f.idx, '');
const panelCable = cableAt(t.panel, t.port, 'front');
let action;
if (existing && panelCable && existing.id === panelCable.id) {
action = `<span class="val-good">Matches ${esc(existing.label || 'the cutsheet')}.</span>`;
} else if (existing) {
action = `That switch port is documented as <a class="cs-id" href="#cable-${esc(existing.id)}">${esc(existing.label || 'an unlabeled cable')}</a>
to ${esc(existing.a_device === f.sw ? existing.b_text : existing.a_text)}.
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-cable" data-id="${esc(existing.id)}">Edit it</button>`;
} else {
action = `<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-action="trace-record"
data-sw="${esc(f.sw)}" data-idx="${esc(f.idx)}" data-panel="${esc(t.panel)}" data-port="${esc(t.port)}">Record cable</button>`;
}
traceStatus(`
<div class="cs-trace-found">${esc(t.panel)} port ${esc(t.port)} → <b>${esc(f.sw)} port ${esc(f.idx)}</b>${f.name ? ` (${esc(f.name)})` : ''}</div>
<div>${action}</div>
<div class="cs-hint">${replugged ? '<span class="val-good">Plugged back in, link is up again.</span>' : 'Plug the cord back in now.'}</div>`);
}
// ── 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 === 'trace-start') startTrace();
else if (action === 'trace-stop') { stopTrace(); traceStatus('Stopped.'); }
else if (action === 'trace-record') {
openCable(null, { a_device: btn.dataset.sw, a_port: btn.dataset.idx, b_device: btn.dataset.panel,
b_port: btn.dataset.port, b_side: 'front' });
}
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);