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:
@@ -2,12 +2,15 @@
|
||||
import logging
|
||||
import threading
|
||||
import os
|
||||
from flask import Flask, jsonify, send_file, render_template, request
|
||||
import csv
|
||||
import io
|
||||
from flask import Flask, jsonify, send_file, render_template, request, Response
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
import db
|
||||
from db import save_node_positions, get_node_positions, clear_node_positions
|
||||
import scanner
|
||||
from nocodb_client import get_switches
|
||||
from config import EXPORTS_DIR
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
@@ -109,6 +112,35 @@ def api_scan_gw():
|
||||
return jsonify({"status": "started", "dept": "GW"})
|
||||
|
||||
|
||||
@app.route("/api/scan/sec", methods=["POST"])
|
||||
def api_scan_sec():
|
||||
"""Scan only SEC department switches."""
|
||||
if scanner.scan_state["running"]:
|
||||
return jsonify({"error": "Scan already running"}), 409
|
||||
workers, login_delay = _scan_params()
|
||||
_trigger_scan_background(dept="SEC", workers=workers, login_delay=login_delay)
|
||||
return jsonify({"status": "started", "dept": "SEC"})
|
||||
|
||||
|
||||
@app.route("/api/scan/ami", methods=["POST"])
|
||||
def api_scan_ami():
|
||||
"""Scan only AMI department switches."""
|
||||
if scanner.scan_state["running"]:
|
||||
return jsonify({"error": "Scan already running"}), 409
|
||||
workers, login_delay = _scan_params()
|
||||
_trigger_scan_background(dept="AMI", workers=workers, login_delay=login_delay)
|
||||
return jsonify({"status": "started", "dept": "AMI"})
|
||||
|
||||
|
||||
@app.route("/api/scan/abort", methods=["POST"])
|
||||
def api_scan_abort():
|
||||
"""Request the in-progress scan to stop after in-flight switches finish."""
|
||||
if not scanner.scan_state["running"]:
|
||||
return jsonify({"error": "No scan running"}), 409
|
||||
scanner.request_abort()
|
||||
return jsonify({"status": "aborting"})
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
state = dict(scanner.scan_state)
|
||||
@@ -124,6 +156,26 @@ def api_switches():
|
||||
return jsonify(db.get_all_switches())
|
||||
|
||||
|
||||
@app.route("/api/switches/inventory")
|
||||
def api_switches_inventory():
|
||||
"""Full active-switch list from NocoDB, for the test-connection device picker."""
|
||||
try:
|
||||
return jsonify(get_switches())
|
||||
except RuntimeError as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
|
||||
|
||||
@app.route("/api/test-connection", methods=["POST"])
|
||||
def api_test_connection():
|
||||
"""Try SSH + LLDP query against a single switch, independent of any running scan."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
ip = (data.get("ip") or "").strip()
|
||||
if not ip:
|
||||
return jsonify({"error": "ip is required"}), 400
|
||||
result = scanner.test_single_switch(ip, manufacturer=data.get("manufacturer", ""))
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/links")
|
||||
def api_links():
|
||||
return jsonify(db.get_all_links())
|
||||
@@ -145,6 +197,7 @@ def api_topology():
|
||||
"description": sw.get("description", ""),
|
||||
"firmware": sw.get("firmware", ""),
|
||||
"vendor": sw.get("vendor", ""),
|
||||
"serial": sw.get("serial", ""),
|
||||
"chassis_id": chassis_id,
|
||||
"last_seen": sw.get("last_seen", ""),
|
||||
}
|
||||
@@ -170,6 +223,31 @@ def api_topology():
|
||||
return jsonify({"nodes": nodes, "edges": edges})
|
||||
|
||||
|
||||
@app.route("/api/credentials", methods=["GET"])
|
||||
def api_get_credentials():
|
||||
"""Never returns the password itself — only whether one is set."""
|
||||
username = db.get_setting("ssh_username") or ""
|
||||
password_set = bool(db.get_setting("ssh_password"))
|
||||
return jsonify({"username": username, "password_set": password_set})
|
||||
|
||||
|
||||
@app.route("/api/credentials", methods=["POST"])
|
||||
def api_set_credentials():
|
||||
"""Update SSH credentials used by scans/tests. Blank password leaves the existing one unchanged."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "Username is required"}), 400
|
||||
|
||||
db.set_setting("ssh_username", username)
|
||||
if password:
|
||||
db.set_setting("ssh_password", password)
|
||||
|
||||
return jsonify({"username": username, "password_set": bool(db.get_setting("ssh_password"))})
|
||||
|
||||
|
||||
@app.route("/api/settings", methods=["POST"])
|
||||
def api_settings():
|
||||
data = request.json
|
||||
@@ -229,6 +307,30 @@ def api_clear_stale_switches():
|
||||
deleted = db.clear_stale_switches(since)
|
||||
return jsonify({"deleted": deleted})
|
||||
|
||||
@app.route("/api/export/serials")
|
||||
def api_export_serials():
|
||||
"""Hostname/IP/Model/Serial for every scanned switch, generated live from the DB —
|
||||
for pasting into NocoDB. Blank Serial means it wasn't found on that switch's
|
||||
'show version' (or 'show system information' for Aruba/HP) output."""
|
||||
switches = db.get_all_switches()
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(['Hostname', 'Mgmt IP', 'Model', 'Vendor', 'Serial'])
|
||||
for sw in sorted(switches, key=lambda s: s.get('hostname') or ''):
|
||||
writer.writerow([
|
||||
sw.get('hostname', ''),
|
||||
sw.get('mgmt_ip', ''),
|
||||
sw.get('description', ''),
|
||||
sw.get('vendor', ''),
|
||||
sw.get('serial', ''),
|
||||
])
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename=switch_serials.csv'},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/export/csv")
|
||||
def api_export_csv():
|
||||
path = os.path.join(EXPORTS_DIR, "topology.csv")
|
||||
@@ -255,4 +357,4 @@ def api_export_png():
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
app.run(host="0.0.0.0", port=5000, debug=False, threaded=True)
|
||||
|
||||
Reference in New Issue
Block a user