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:
@@ -37,11 +37,12 @@ def _reschedule(interval_minutes):
|
|||||||
logger.info(f"Auto-scan scheduled every {interval_minutes} 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."""
|
"""Run scan in a background thread. dept=None → all switches."""
|
||||||
if not scanner.scan_state["running"]:
|
if not scanner.scan_state["running"]:
|
||||||
t = threading.Thread(target=scanner.run_scan,
|
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)
|
daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
@@ -132,6 +133,25 @@ def api_scan_ami():
|
|||||||
return jsonify({"status": "started", "dept": "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"])
|
@app.route("/api/scan/abort", methods=["POST"])
|
||||||
def api_scan_abort():
|
def api_scan_abort():
|
||||||
"""Request the in-progress scan to stop after in-flight switches finish."""
|
"""Request the in-progress scan to stop after in-flight switches finish."""
|
||||||
|
|||||||
+40
-3
@@ -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>
|
<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
|
Scan AMI
|
||||||
</button>
|
</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()">
|
<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">
|
<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"/>
|
<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" id="testConnModal" style="display:none">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span>Test Connection</span>
|
<span id="testConnTitle">Test Connection</span>
|
||||||
<button class="modal-close" onclick="closeModals()">✕</button>
|
<button class="modal-close" onclick="closeModals()">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn-sm" onclick="closeModals()">Cancel</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1120,7 +1124,13 @@ async function saveCredentials() {
|
|||||||
msg.className = 'modal-msg ok';
|
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('modalOverlay').classList.add('visible');
|
||||||
document.getElementById('testConnModal').style.display = 'block';
|
document.getElementById('testConnModal').style.display = 'block';
|
||||||
const result = document.getElementById('testConnResult');
|
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() {
|
function startPolling() {
|
||||||
if (pollInterval) return;
|
if (pollInterval) return;
|
||||||
pollInterval = setInterval(pollStatus, 1500);
|
pollInterval = setInterval(pollStatus, 1500);
|
||||||
@@ -1201,6 +1236,7 @@ async function pollStatus() {
|
|||||||
document.getElementById('scanGwBtn').disabled = true;
|
document.getElementById('scanGwBtn').disabled = true;
|
||||||
document.getElementById('scanSecBtn').disabled = true;
|
document.getElementById('scanSecBtn').disabled = true;
|
||||||
document.getElementById('scanAmiBtn').disabled = true;
|
document.getElementById('scanAmiBtn').disabled = true;
|
||||||
|
document.getElementById('scanOneBtn').disabled = true;
|
||||||
document.getElementById('clearScanBtn').disabled = true;
|
document.getElementById('clearScanBtn').disabled = true;
|
||||||
document.getElementById('abortScanBtn').style.display = '';
|
document.getElementById('abortScanBtn').style.display = '';
|
||||||
progressWrap.classList.add('visible');
|
progressWrap.classList.add('visible');
|
||||||
@@ -1266,6 +1302,7 @@ async function pollStatus() {
|
|||||||
document.getElementById('scanGwBtn').disabled = false;
|
document.getElementById('scanGwBtn').disabled = false;
|
||||||
document.getElementById('scanSecBtn').disabled = false;
|
document.getElementById('scanSecBtn').disabled = false;
|
||||||
document.getElementById('scanAmiBtn').disabled = false;
|
document.getElementById('scanAmiBtn').disabled = false;
|
||||||
|
document.getElementById('scanOneBtn').disabled = false;
|
||||||
document.getElementById('clearScanBtn').disabled = false;
|
document.getElementById('clearScanBtn').disabled = false;
|
||||||
document.getElementById('abortScanBtn').style.display = 'none';
|
document.getElementById('abortScanBtn').style.display = 'none';
|
||||||
progressBar.style.width = '100%';
|
progressBar.style.width = '100%';
|
||||||
|
|||||||
+8
-4
@@ -39,10 +39,12 @@ def request_abort():
|
|||||||
return False
|
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.
|
Full scan: fetch switch IPs from NocoDB → SSH all → parse → store → export.
|
||||||
dept: None = all active switches, "ELEC" or "GW" = dept-filtered.
|
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
|
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)
|
# Fetch switch list from NocoDB (or fallback)
|
||||||
switches = get_switches(dept=dept)
|
switches = get_switches(dept=dept)
|
||||||
|
if only_ip:
|
||||||
|
switches = [s for s in switches if s["ip"] == only_ip]
|
||||||
|
|
||||||
if not switches:
|
if not switches:
|
||||||
logger.error("NocoDB returned no switches — aborting scan.")
|
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,
|
"fail": 0,
|
||||||
"errors": [],
|
"errors": [],
|
||||||
"log_lines": [],
|
"log_lines": [],
|
||||||
"dept_filter": dept,
|
"dept_filter": dept or only_ip,
|
||||||
"aborted": False,
|
"aborted": False,
|
||||||
})
|
})
|
||||||
|
|
||||||
scan_id = log_scan_start()
|
scan_id = log_scan_start()
|
||||||
if dept is None:
|
if dept is None and only_ip is None:
|
||||||
clear_links() # Full scan only — dept scans upsert without wiping other depts
|
clear_links() # Full scan only — dept/single-switch scans upsert without wiping the rest
|
||||||
|
|
||||||
def on_progress(done, total, ip, result):
|
def on_progress(done, total, ip, result):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
Reference in New Issue
Block a user