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:
+264
-1
@@ -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 & 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 — ` +
|
||||
`<span class="ok-count">✓ ${s.ok}</span> ` +
|
||||
`<span class="fail-count">✗ ${s.fail}</span>`;
|
||||
} else if (s.fail > 0) {
|
||||
statusText.innerHTML =
|
||||
`Scan complete — ` +
|
||||
`<span class="ok-count">✓ ${s.ok}</span> ` +
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user