From 92ded499d5e2999072e33490c461ebe7a64053af Mon Sep 17 00:00:00 2001 From: dcstephenson Date: Thu, 1 Oct 2026 15:18:01 -0500 Subject: [PATCH] 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 --- app.py | 24 ++++++++++++++++++++++-- index.html | 43 ++++++++++++++++++++++++++++++++++++++++--- scanner.py | 12 ++++++++---- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index 17dd32d..be93a69 100644 --- a/app.py +++ b/app.py @@ -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.""" diff --git a/index.html b/index.html index 35a1b39..73c8115 100644 --- a/index.html +++ b/index.html @@ -611,6 +611,10 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1 Scan AMI + @@ -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%'; diff --git a/scanner.py b/scanner.py index 476df99..43068f4 100644 --- a/scanner.py +++ b/scanner.py @@ -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