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
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>
706 lines
32 KiB
JavaScript
706 lines
32 KiB
JavaScript
'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">╚</span><span class="lt-frame-br">╝</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);
|