Add single-switch scan

New "Scan Switch" header button reuses the Test Connection picker and
posts to /api/scan/switch, which runs the normal scan pipeline on one
switch from the NocoDB inventory. Like a dept scan it does not clear
links first, so the rest of the map is left in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
dcstephenson
2026-10-01 15:18:01 -05:00
parent e589f76387
commit 92ded499d5
3 changed files with 70 additions and 9 deletions
+22 -2
View File
@@ -37,11 +37,12 @@ def _reschedule(interval_minutes):
logger.info(f"Auto-scan scheduled every {interval_minutes} minutes")
def _trigger_scan_background(dept: str = None, workers: int = 5, login_delay: int = 3):
def _trigger_scan_background(dept: str = None, workers: int = 5, login_delay: int = 3, only_ip: str = None):
"""Run scan in a background thread. dept=None → all switches."""
if not scanner.scan_state["running"]:
t = threading.Thread(target=scanner.run_scan,
kwargs={"dept": dept, "workers": workers, "login_delay": login_delay},
kwargs={"dept": dept, "workers": workers, "login_delay": login_delay,
"only_ip": only_ip},
daemon=True)
t.start()
@@ -132,6 +133,25 @@ def api_scan_ami():
return jsonify({"status": "started", "dept": "AMI"})
@app.route("/api/scan/switch", methods=["POST"])
def api_scan_switch():
"""Scan a single switch (by IP, must be in the NocoDB inventory)."""
if scanner.scan_state["running"]:
return jsonify({"error": "Scan already running"}), 409
ip = ((request.get_json(silent=True) or {}).get("ip") or "").strip()
if not ip:
return jsonify({"error": "ip is required"}), 400
try:
known = any(s["ip"] == ip for s in get_switches())
except RuntimeError as e:
return jsonify({"error": str(e)}), 502
if not known:
return jsonify({"error": f"{ip} is not an active switch in NocoDB"}), 404
workers, login_delay = _scan_params()
_trigger_scan_background(workers=workers, login_delay=login_delay, only_ip=ip)
return jsonify({"status": "started", "ip": ip})
@app.route("/api/scan/abort", methods=["POST"])
def api_scan_abort():
"""Request the in-progress scan to stop after in-flight switches finish."""
+40 -3
View File
@@ -611,6 +611,10 @@ 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="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-primary" id="scanOneBtn" onclick="openTestConnModal('scan')" title="Scan a single switch without touching the rest of the map">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
Scan Switch
</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"/>
@@ -725,7 +729,7 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<div class="modal" id="testConnModal" style="display:none">
<div class="modal-header">
<span>Test Connection</span>
<span id="testConnTitle">Test Connection</span>
<button class="modal-close" onclick="closeModals()">✕</button>
</div>
<div class="modal-body">
@@ -735,7 +739,7 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
</div>
<div class="modal-footer">
<button class="btn-sm" onclick="closeModals()">Cancel</button>
<button class="btn btn-primary" id="testConnRunBtn" onclick="runTestConnection()">Test</button>
<button class="btn btn-primary" id="testConnRunBtn" onclick="switchPickerMode === 'scan' ? runScanOne() : runTestConnection()">Test</button>
</div>
</div>
@@ -1120,7 +1124,13 @@ async function saveCredentials() {
msg.className = 'modal-msg ok';
}
async function openTestConnModal() {
// The switch picker modal serves both Test Connection and Scan Switch.
let switchPickerMode = 'test';
async function openTestConnModal(mode = 'test') {
switchPickerMode = mode;
document.getElementById('testConnTitle').textContent = mode === 'scan' ? 'Scan One Switch' : 'Test Connection';
document.getElementById('testConnRunBtn').textContent = mode === 'scan' ? 'Scan' : 'Test';
document.getElementById('modalOverlay').classList.add('visible');
document.getElementById('testConnModal').style.display = 'block';
const result = document.getElementById('testConnResult');
@@ -1178,6 +1188,31 @@ async function runTestConnection() {
}
}
async function runScanOne() {
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 res = await fetch('/api/scan/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: opt.value, ...scanSettings() }),
});
if (res.status === 409) { alert('Scan already running'); return; }
if (!res.ok) {
const d = await res.json().catch(() => ({}));
result.className = 'modal-msg fail';
result.textContent = `✗ ${d.error || 'Scan failed to start'}`;
return;
}
closeModals();
startPolling();
}
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(pollStatus, 1500);
@@ -1201,6 +1236,7 @@ async function pollStatus() {
document.getElementById('scanGwBtn').disabled = true;
document.getElementById('scanSecBtn').disabled = true;
document.getElementById('scanAmiBtn').disabled = true;
document.getElementById('scanOneBtn').disabled = true;
document.getElementById('clearScanBtn').disabled = true;
document.getElementById('abortScanBtn').style.display = '';
progressWrap.classList.add('visible');
@@ -1266,6 +1302,7 @@ async function pollStatus() {
document.getElementById('scanGwBtn').disabled = false;
document.getElementById('scanSecBtn').disabled = false;
document.getElementById('scanAmiBtn').disabled = false;
document.getElementById('scanOneBtn').disabled = false;
document.getElementById('clearScanBtn').disabled = false;
document.getElementById('abortScanBtn').style.display = 'none';
progressBar.style.width = '100%';
+8 -4
View File
@@ -39,10 +39,12 @@ def request_abort():
return False
def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3, only_ip: str = None):
"""
Full scan: fetch switch IPs from NocoDB → SSH all → parse → store → export.
dept: None = all active switches, "ELEC" or "GW" = dept-filtered.
only_ip: scan just that one switch from the list; like a dept scan, it
leaves every other switch's links in place.
"""
global scan_state
@@ -54,6 +56,8 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
# Fetch switch list from NocoDB (or fallback)
switches = get_switches(dept=dept)
if only_ip:
switches = [s for s in switches if s["ip"] == only_ip]
if not switches:
logger.error("NocoDB returned no switches — aborting scan.")
@@ -72,13 +76,13 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
"fail": 0,
"errors": [],
"log_lines": [],
"dept_filter": dept,
"dept_filter": dept or only_ip,
"aborted": False,
})
scan_id = log_scan_start()
if dept is None:
clear_links() # Full scan only — dept scans upsert without wiping other depts
if dept is None and only_ip is None:
clear_links() # Full scan only — dept/single-switch scans upsert without wiping the rest
def on_progress(done, total, ip, result):
from datetime import datetime