Add SEC/AMI scans, scan abort, connection test, runtime credentials, serial tracking

Also fixes several LLDP parsing edge cases (blank-field regex bleed,
endpoint-device filtering, HP ProCurve chassis ID format, Aruba port
naming), corrects link dedup to prefer self-reported port names over
neighbor guesses, and repoints NetBox sync at the new instance where
devices are hand-curated rather than auto-created.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp1HutYUh9fmTFgwB4qBLz
This commit is contained in:
dcstephenson
2026-07-24 10:40:55 -05:00
parent 3a93c6d6ed
commit 5ffadfae17
7 changed files with 583 additions and 118 deletions
+264 -1
View File
@@ -464,6 +464,85 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
.btn-elec:hover { background: #e05a00; }
.btn-gw { background: #1a5c2a; color: #fff; }
.btn-gw:hover { background: #247a39; }
.btn-sec { background: #7a2a00; color: #fff; }
.btn-sec:hover { background: #a83900; }
.btn-ami { background: #5500aa; color: #fff; }
.btn-ami:hover { background: #7700e0; }
/* ── Modals ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.55);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-overlay.visible { display: flex; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
width: 380px;
max-width: 90vw;
box-shadow: 0 10px 40px rgba(0,0,0,.4);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 14px;
}
.modal-close {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
}
.modal-close:hover { color: var(--text); }
.modal-body {
padding: 16px;
display: flex;
flex-direction: column;
}
.modal-label {
font-size: 12px;
color: var(--text-dim);
margin: 10px 0 6px;
}
.modal-label:first-child { margin-top: 0; }
.modal-hint { font-weight: 400; }
.modal-input {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
width: 100%;
font-family: inherit;
}
.modal-input:focus { outline: none; border-color: var(--accent); }
.modal-msg {
font-size: 12px;
margin-top: 10px;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-msg.ok { color: var(--green); }
.modal-msg.fail { color: var(--red); }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
</style>
</head>
<body>
@@ -507,6 +586,9 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<span class="setting-val" id="delayVal">3s</span>
</div>
<button class="btn-sm" onclick="openTestConnModal()" title="Test SSH connection to one device">⚡ Test Connection</button>
<button class="btn-sm" onclick="openCredentialsModal()" title="Update SSH credentials">🔑 Credentials</button>
<button class="btn btn-primary" id="scanBtn" onclick="triggerScan()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
@@ -521,12 +603,26 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M12 2C6 2 2 7 2 12s4 10 10 10 10-4.5 10-10S18 2 12 2zm0 4c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm4 12H8v-1c0-2.7 5.3-4 8-4v5z"/></svg>
Scan GW
</button>
<button class="btn btn-sec" id="scanSecBtn" onclick="triggerScan('sec')">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M12 2l8 4v6c0 5-3.4 8.7-8 10-4.6-1.3-8-5-8-10V6l8-4z"/></svg>
Scan SEC
</button>
<button class="btn btn-ami" id="scanAmiBtn" onclick="triggerScan('ami')">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M4 14a8 8 0 0116 0M4 14h16M4 14l-1 6h18l-1-6M12 14V6m0 0l-3 3m3-3l3 3"/></svg>
Scan AMI
</button>
<button class="btn btn-danger" id="clearScanBtn" onclick="clearAndRescan()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6m4-6v6"/><path d="M9 6V4h6v2"/>
</svg>
Clear &amp; Rescan
</button>
<button class="btn btn-danger" id="abortScanBtn" onclick="abortScan()" style="display:none">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1"/>
</svg>
Abort
</button>
</div>
</header>
@@ -591,6 +687,7 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<button class="btn-sm" onclick="exportFile('csv')">⬇ CSV</button>
<button class="btn-sm" onclick="exportFile('mermaid')">⬇ Mermaid</button>
<button class="btn-sm" onclick="exportFile('png')">⬇ PNG</button>
<button class="btn-sm" onclick="exportFile('serials')" title="Hostname/IP/Model/Serial for pasting into NocoDB">⬇ Serials</button>
</div>
</div>
</div>
@@ -605,6 +702,45 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<div class="scan-log-body" id="scanLogBody"></div>
</div>
<!-- Modals -->
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this) closeModals()">
<div class="modal" id="credentialsModal" style="display:none">
<div class="modal-header">
<span>SSH Credentials</span>
<button class="modal-close" onclick="closeModals()"></button>
</div>
<div class="modal-body">
<div class="modal-label">Username</div>
<input type="text" id="credUsername" class="modal-input" autocomplete="off">
<div class="modal-label">Password <span class="modal-hint" id="credPasswordHint"></span></div>
<input type="password" id="credPassword" class="modal-input" autocomplete="new-password" placeholder="Leave blank to keep current password">
<div class="modal-msg" id="credMsg"></div>
</div>
<div class="modal-footer">
<button class="btn-sm" onclick="closeModals()">Cancel</button>
<button class="btn btn-primary" onclick="saveCredentials()">Save</button>
</div>
</div>
<div class="modal" id="testConnModal" style="display:none">
<div class="modal-header">
<span>Test Connection</span>
<button class="modal-close" onclick="closeModals()"></button>
</div>
<div class="modal-body">
<div class="modal-label">Switch</div>
<select id="testConnSelect" class="modal-input"><option>Loading…</option></select>
<div class="modal-msg" id="testConnResult"></div>
</div>
<div class="modal-footer">
<button class="btn-sm" onclick="closeModals()">Cancel</button>
<button class="btn btn-primary" id="testConnRunBtn" onclick="runTestConnection()">Test</button>
</div>
</div>
</div>
<script>
/**
* Copyright (c) 2016-2025, The Cytoscape Consortium.
@@ -827,6 +963,10 @@ function showNodeDetail(data) {
<span class="detail-key">Firmware</span>
<span class="detail-val">${data.firmware || '—'}</span>
</div>
<div class="detail-row">
<span class="detail-key">Serial</span>
<span class="detail-val">${data.serial || '—'}</span>
</div>
<div class="detail-row">
<span class="detail-key">Last Seen</span>
<span class="detail-val">${data.last_seen || '—'}</span>
@@ -916,6 +1056,8 @@ function scanSettings() {
async function triggerScan(dept) {
const url = dept === 'elec' ? '/api/scan/elec'
: dept === 'gw' ? '/api/scan/gw'
: dept === 'sec' ? '/api/scan/sec'
: dept === 'ami' ? '/api/scan/ami'
: '/api/scan';
const res = await fetch(url, {
method: 'POST',
@@ -926,6 +1068,116 @@ async function triggerScan(dept) {
startPolling();
}
async function abortScan() {
if (!confirm('Abort the running scan? Switches already logged in will finish; the rest are skipped.')) return;
const res = await fetch('/api/scan/abort', { method: 'POST' });
if (res.status === 409) alert('No scan running');
}
// ── Modals ──
function closeModals() {
document.getElementById('modalOverlay').classList.remove('visible');
document.getElementById('credentialsModal').style.display = 'none';
document.getElementById('testConnModal').style.display = 'none';
}
async function openCredentialsModal() {
document.getElementById('modalOverlay').classList.add('visible');
document.getElementById('credentialsModal').style.display = 'block';
const msg = document.getElementById('credMsg');
msg.textContent = '';
msg.className = 'modal-msg';
document.getElementById('credPassword').value = '';
const res = await fetch('/api/credentials');
const d = await res.json();
document.getElementById('credUsername').value = d.username || '';
document.getElementById('credPasswordHint').textContent = d.password_set ? '(currently set — leave blank to keep it)' : '(not set)';
}
async function saveCredentials() {
const username = document.getElementById('credUsername').value.trim();
const password = document.getElementById('credPassword').value;
const msg = document.getElementById('credMsg');
if (!username) {
msg.textContent = 'Username is required';
msg.className = 'modal-msg fail';
return;
}
const res = await fetch('/api/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const d = await res.json();
if (d.error) {
msg.textContent = d.error;
msg.className = 'modal-msg fail';
return;
}
document.getElementById('credPassword').value = '';
document.getElementById('credPasswordHint').textContent = d.password_set ? '(currently set — leave blank to keep it)' : '(not set)';
msg.textContent = 'Saved.';
msg.className = 'modal-msg ok';
}
async function openTestConnModal() {
document.getElementById('modalOverlay').classList.add('visible');
document.getElementById('testConnModal').style.display = 'block';
const result = document.getElementById('testConnResult');
result.textContent = '';
result.className = 'modal-msg';
const sel = document.getElementById('testConnSelect');
sel.innerHTML = '<option>Loading…</option>';
try {
const res = await fetch('/api/switches/inventory');
const list = await res.json();
if (list.error) { sel.innerHTML = `<option value="">${list.error}</option>`; return; }
if (!list.length) { sel.innerHTML = '<option value="">No active switches found</option>'; return; }
sel.innerHTML = list.map(s =>
`<option value="${s.ip}" data-manufacturer="${s.manufacturer || ''}">${s.hostname || s.ip} (${s.ip})${s.dept ? ' — ' + s.dept : ''}</option>`
).join('');
} catch (e) {
sel.innerHTML = '<option value="">Failed to load switches</option>';
}
}
async function runTestConnection() {
const sel = document.getElementById('testConnSelect');
const opt = sel.options[sel.selectedIndex];
const result = document.getElementById('testConnResult');
if (!opt || !opt.value) {
result.className = 'modal-msg fail';
result.textContent = 'No switch selected';
return;
}
const ip = opt.value;
const manufacturer = opt.getAttribute('data-manufacturer') || '';
const btn = document.getElementById('testConnRunBtn');
btn.disabled = true;
result.className = 'modal-msg';
result.textContent = `Connecting to ${ip}…`;
try {
const res = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip, manufacturer }),
});
const d = await res.json();
if (d.success) {
result.className = 'modal-msg ok';
result.textContent = `✓ ${d.hostname} [${d.vendor}]\n${d.neighbors.length} LLDP neighbor(s)\nFirmware: ${d.firmware || 'n/a'}`;
} else {
result.className = 'modal-msg fail';
result.textContent = `✗ ${d.error || 'Unknown error'}`;
}
} catch (e) {
result.className = 'modal-msg fail';
result.textContent = `✗ Request failed: ${e}`;
} finally {
btn.disabled = false;
}
}
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(pollStatus, 1500);
@@ -947,7 +1199,10 @@ async function pollStatus() {
scanBtn.disabled = true;
document.getElementById('scanElecBtn').disabled = true;
document.getElementById('scanGwBtn').disabled = true;
document.getElementById('scanSecBtn').disabled = true;
document.getElementById('scanAmiBtn').disabled = true;
document.getElementById('clearScanBtn').disabled = true;
document.getElementById('abortScanBtn').style.display = '';
progressWrap.classList.add('visible');
statusBar.classList.add('visible');
document.getElementById('scanSpinner').style.display = '';
@@ -991,7 +1246,12 @@ async function pollStatus() {
logBody.scrollTop = logBody.scrollHeight;
spinner.style.display = 'none';
if (s.fail > 0) {
if (s.aborted) {
statusText.innerHTML =
`⚠ Scan aborted &nbsp;&nbsp; ` +
`<span class="ok-count">✓ ${s.ok}</span>&nbsp;&nbsp;` +
`<span class="fail-count">✗ ${s.fail}</span>`;
} else if (s.fail > 0) {
statusText.innerHTML =
`Scan complete &nbsp;&nbsp; ` +
`<span class="ok-count">✓ ${s.ok}</span>&nbsp;&nbsp;` +
@@ -1004,7 +1264,10 @@ async function pollStatus() {
scanBtn.disabled = false;
document.getElementById('scanElecBtn').disabled = false;
document.getElementById('scanGwBtn').disabled = false;
document.getElementById('scanSecBtn').disabled = false;
document.getElementById('scanAmiBtn').disabled = false;
document.getElementById('clearScanBtn').disabled = false;
document.getElementById('abortScanBtn').style.display = 'none';
progressBar.style.width = '100%';
setTimeout(() => {
progressWrap.classList.remove('visible');