Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m9s
Lint / Python (flake8) (pull_request) Successful in 57s
Test / Python Tests (pytest) (push) Successful in 1m14s
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s
Gear on the rear posts usually still faces the front (the StarTech's RPDU and ZimaBoard shelf), so "which posts" and "which way the ports face" are now separate fields (mount, facing). The page has three views: Front (front posts), Rear posts (rear-post gear seen from the front, i.e. what is behind the front gear) and Rear (from behind). An item seen from the side it doesn't face shows its back. Facing defaults to front, so existing items keep their meaning. Shelves are drawn at true scale with their contents ordered by a new "Position on shelf". The IceWhale ZimaBoard 832 is traced from IceWhale's labelled photo of its port end (ZimaDocs): Mini-DP, two RJ45-over-USB 3.0 stacks with link/activity LEDs, DC jack, gunmetal case, gold PCB edge and black base. Its RJ45 link lights follow the host's physical NICs and the status glow follows the host. New cutsheet_items columns facing and slot, added on first use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
723 lines
33 KiB
JavaScript
723 lines
33 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 rackText(i) {
|
||
if (i.shelf) return `on ${esc(i.shelf)}${i.slot ? `, position ${esc(i.slot)}` : ''}`;
|
||
if (!i.rack_u) return '-';
|
||
const side = ({ rear: ' rear posts', full: ' full depth' }[i.mount] || '') + (i.facing === 'rear' ? ', faces rear' : '');
|
||
const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}${side}`;
|
||
return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where;
|
||
}
|
||
|
||
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>${rackText(i)}</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', mount: 'front', facing: 'front' }, item || {});
|
||
document.getElementById('cs-rack-list').innerHTML = (_cs.racks || []).map(r => `<option value="${esc(r)}">`).join('');
|
||
document.getElementById('cs-known-devices').innerHTML = (_cs.devices || []).map(d => `<option value="${esc(d)}">`).join('');
|
||
form.chassis.innerHTML = '<option value="">Automatic (faceplate or plain plate)</option>' +
|
||
Object.entries(_cs.chassis || {}).map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`).join('');
|
||
const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id));
|
||
form.shelf.innerHTML = '<option value="">Not on a shelf</option>' +
|
||
shelves.map(i => `<option value="${esc(i.name)}">${esc(i.name)}</option>`).join('');
|
||
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'facing',
|
||
'shelf', 'slot', 'chassis', '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', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'facing',
|
||
'shelf', 'slot', 'chassis', '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);
|