diff --git a/app.py b/app.py index d5f1553..17dd32d 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/db.py b/db.py index a84ea1d..040fb12 100644 --- a/db.py +++ b/db.py @@ -3,7 +3,7 @@ import sqlite3 import re import os import logging -from config import DB_PATH +from config import DB_PATH, SSH_USERNAME, SSH_PASSWORD logger = logging.getLogger(__name__) @@ -30,8 +30,8 @@ def init_db(): last_seen TEXT ) """) - # Migrate existing DBs that predate firmware/vendor columns - for col, default in [('firmware', ''), ('vendor', '')]: + # Migrate existing DBs that predate firmware/vendor/serial columns + for col, default in [('firmware', ''), ('vendor', ''), ('serial', '')]: try: c.execute(f"ALTER TABLE switches ADD COLUMN {col} TEXT DEFAULT '{default}'") except Exception: @@ -68,6 +68,10 @@ def init_db(): c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_enabled', 'false')") c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_interval', '60')") + # Seed SSH credentials from config.py on first run only — the settings + # table is the source of truth from then on (editable via /api/credentials). + c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('ssh_username', ?)", (SSH_USERNAME,)) + c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('ssh_password', ?)", (SSH_PASSWORD,)) c.execute( "CREATE TABLE IF NOT EXISTS node_positions " @@ -78,11 +82,11 @@ def init_db(): conn.close() -def upsert_switch(chassis_id, hostname, mgmt_ip, description, firmware='', vendor=''): +def upsert_switch(chassis_id, hostname, mgmt_ip, description, firmware='', vendor='', serial=''): conn = get_conn() conn.execute(""" - INSERT INTO switches (chassis_id, hostname, mgmt_ip, description, firmware, vendor, last_seen) - VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + INSERT INTO switches (chassis_id, hostname, mgmt_ip, description, firmware, vendor, serial, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(chassis_id) DO UPDATE SET hostname = excluded.hostname, mgmt_ip = CASE WHEN excluded.mgmt_ip != '' AND excluded.mgmt_ip LIKE '%.%.%.%' @@ -90,8 +94,9 @@ def upsert_switch(chassis_id, hostname, mgmt_ip, description, firmware='', vendo description = CASE WHEN excluded.description != '' THEN excluded.description ELSE description END, firmware = CASE WHEN excluded.firmware != '' THEN excluded.firmware ELSE firmware END, vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END, + serial = CASE WHEN excluded.serial != '' THEN excluded.serial ELSE serial END, last_seen = excluded.last_seen - """, (chassis_id, hostname, mgmt_ip, description, firmware, vendor)) + """, (chassis_id, hostname, mgmt_ip, description, firmware, vendor, serial)) conn.commit() conn.close() @@ -103,10 +108,23 @@ def _port_num(port): def upsert_link(chassis_a, port_a, chassis_b, port_b): + """ + chassis_a/port_a is always the scanned switch's own self-reported port — + trustworthy. chassis_b/port_b is that switch's perception of its neighbor's + port, which for LLDP is often the neighbor's raw wire-transmitted Port ID + (an ifIndex-style number) rather than the neighbor's own friendly port name. + When the neighbor later gets scanned directly, its own upsert_link call + supplies the trustworthy label for what is now the "chassis_b" side here — + so on a match we overwrite that side with the newly-known self-report + instead of leaving the untrusted guess in place. + """ + self_chassis, self_port = chassis_a, port_a + # Normalize order so A→B and B→A are treated as the same link if chassis_a > chassis_b: chassis_a, chassis_b = chassis_b, chassis_a port_a, port_b = port_b, port_a + conn = get_conn() # Exact match first existing = conn.execute(""" @@ -116,13 +134,28 @@ def upsert_link(chassis_a, port_a, chassis_b, port_b): # Fuzzy match: same chassis pair, same trailing port numbers # handles 'Gi1/9' vs '9' reported by different vendors for the same cable rows = conn.execute(""" - SELECT port_a, port_b FROM links WHERE chassis_a=? AND chassis_b=? + SELECT id, port_a, port_b FROM links WHERE chassis_a=? AND chassis_b=? """, (chassis_a, chassis_b)).fetchall() for r in rows: - if _port_num(port_a) == _port_num(r[0]) and _port_num(port_b) == _port_num(r[1]): - existing = True + if _port_num(port_a) == _port_num(r[1]) and _port_num(port_b) == _port_num(r[2]): + existing = r break - if not existing: + # A single physical port can only carry one live link, so an exact + # match on either side's port name (e.g. Aruba's front-panel label + # 'E5' vs the ifIndex-style '101' its neighbors report for it) means + # this is the same cable even though the trailing digits don't align. + if port_a == r[1] or port_b == r[2]: + existing = r + break + + if existing: + # Overwrite whichever stored side corresponds to the self-reporting + # switch with its trusted value — it may currently hold the neighbor's + # untrusted guess from when this link was first inserted. + col = 'port_a' if self_chassis == chassis_a else 'port_b' + conn.execute(f"UPDATE links SET {col}=? WHERE id=?", (self_port, existing['id'])) + conn.commit() + else: conn.execute(""" INSERT OR IGNORE INTO links (chassis_a, port_a, chassis_b, port_b) VALUES (?, ?, ?, ?) diff --git a/index.html b/index.html index 45faae7..35a1b39 100644 --- a/index.html +++ b/index.html @@ -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); + }
@@ -507,6 +586,9 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1 3s + + + + + + @@ -591,6 +687,7 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1 + @@ -605,6 +702,45 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1 + + +