Files
gandalf/static/cutsheet.js
T
jaredandClaude Opus 5.5 b9412ff6ba
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 1m36s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m40s
Lint / Python (flake8) (pull_request) Successful in 52s
Test / Python Tests (pytest) (push) Successful in 1m4s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m13s
Test / Python Tests (pytest) (pull_request) Successful in 1m15s
Add cutsheets: planned cabling checked against discovery
A new Cutsheet page records where every cable is meant to go and compares
it with what Gandalf discovers.

- Inventory for what discovery can't see: patch panels, wall jacks,
  shelves and rack positions. Panels and jacks pass through front to rear.
- Cables with two endpoints, ID, type, length, color, status (planned,
  installed, dark, retired) and notes; the far end may be unterminated.
- Each cable's full run is traced through panels and jacks, and runs
  ending on a switch port are checked against its discovered neighbor
  (LLDP, client table, or a DAC serial seen at both ends): matches,
  wrong neighbor, no link, missing port, port conflict, unverified, idle.
- Undocumented links: live switch ports no cable mentions, one click to
  add with what Gandalf already knows. Port panels show the planned cable.
- Labels to make: cable IDs and panel-port labels not yet applied, with a
  print view, for a handheld label maker.
- CSV export, and all-or-nothing import that upserts by cable ID.
- Cables, panels and jacks appear in the dashboard search.

Editing needs the admin group and a JSON request body. Every change is
written to cutsheet_history in the same transaction. Tables are created
on first use.

Refs #12

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

567 lines
26 KiB
JavaScript
Raw 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();
}
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-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();
}
// ── 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 === '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);