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)
|
||||
|
||||
@@ -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 (?, ?, ?, ?)
|
||||
|
||||
+264
-1
@@ -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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -507,6 +586,9 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
|
||||
<span class="setting-val" id="delayVal">3s</span>
|
||||
</div>
|
||||
|
||||
<button class="btn-sm" onclick="openTestConnModal()" title="Test SSH connection to one device">⚡ Test Connection</button>
|
||||
<button class="btn-sm" onclick="openCredentialsModal()" title="Update SSH credentials">🔑 Credentials</button>
|
||||
|
||||
<button class="btn btn-primary" id="scanBtn" onclick="triggerScan()">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
@@ -521,12 +603,26 @@ 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="M12 2C6 2 2 7 2 12s4 10 10 10 10-4.5 10-10S18 2 12 2zm0 4c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm4 12H8v-1c0-2.7 5.3-4 8-4v5z"/></svg>
|
||||
Scan GW
|
||||
</button>
|
||||
<button class="btn btn-sec" id="scanSecBtn" onclick="triggerScan('sec')">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M12 2l8 4v6c0 5-3.4 8.7-8 10-4.6-1.3-8-5-8-10V6l8-4z"/></svg>
|
||||
Scan SEC
|
||||
</button>
|
||||
<button class="btn btn-ami" id="scanAmiBtn" onclick="triggerScan('ami')">
|
||||
<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-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"/>
|
||||
</svg>
|
||||
Clear & Rescan
|
||||
</button>
|
||||
<button class="btn btn-danger" id="abortScanBtn" onclick="abortScan()" style="display:none">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1"/>
|
||||
</svg>
|
||||
Abort
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -591,6 +687,7 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
|
||||
<button class="btn-sm" onclick="exportFile('csv')">⬇ CSV</button>
|
||||
<button class="btn-sm" onclick="exportFile('mermaid')">⬇ Mermaid</button>
|
||||
<button class="btn-sm" onclick="exportFile('png')">⬇ PNG</button>
|
||||
<button class="btn-sm" onclick="exportFile('serials')" title="Hostname/IP/Model/Serial for pasting into NocoDB">⬇ Serials</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -605,6 +702,45 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
|
||||
<div class="scan-log-body" id="scanLogBody"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this) closeModals()">
|
||||
|
||||
<div class="modal" id="credentialsModal" style="display:none">
|
||||
<div class="modal-header">
|
||||
<span>SSH Credentials</span>
|
||||
<button class="modal-close" onclick="closeModals()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-label">Username</div>
|
||||
<input type="text" id="credUsername" class="modal-input" autocomplete="off">
|
||||
<div class="modal-label">Password <span class="modal-hint" id="credPasswordHint"></span></div>
|
||||
<input type="password" id="credPassword" class="modal-input" autocomplete="new-password" placeholder="Leave blank to keep current password">
|
||||
<div class="modal-msg" id="credMsg"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-sm" onclick="closeModals()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveCredentials()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="testConnModal" style="display:none">
|
||||
<div class="modal-header">
|
||||
<span>Test Connection</span>
|
||||
<button class="modal-close" onclick="closeModals()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-label">Switch</div>
|
||||
<select id="testConnSelect" class="modal-input"><option>Loading…</option></select>
|
||||
<div class="modal-msg" id="testConnResult"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-sm" onclick="closeModals()">Cancel</button>
|
||||
<button class="btn btn-primary" id="testConnRunBtn" onclick="runTestConnection()">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Copyright (c) 2016-2025, The Cytoscape Consortium.
|
||||
@@ -827,6 +963,10 @@ function showNodeDetail(data) {
|
||||
<span class="detail-key">Firmware</span>
|
||||
<span class="detail-val">${data.firmware || '—'}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-key">Serial</span>
|
||||
<span class="detail-val">${data.serial || '—'}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-key">Last Seen</span>
|
||||
<span class="detail-val">${data.last_seen || '—'}</span>
|
||||
@@ -916,6 +1056,8 @@ function scanSettings() {
|
||||
async function triggerScan(dept) {
|
||||
const url = dept === 'elec' ? '/api/scan/elec'
|
||||
: dept === 'gw' ? '/api/scan/gw'
|
||||
: dept === 'sec' ? '/api/scan/sec'
|
||||
: dept === 'ami' ? '/api/scan/ami'
|
||||
: '/api/scan';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -926,6 +1068,116 @@ async function triggerScan(dept) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function abortScan() {
|
||||
if (!confirm('Abort the running scan? Switches already logged in will finish; the rest are skipped.')) return;
|
||||
const res = await fetch('/api/scan/abort', { method: 'POST' });
|
||||
if (res.status === 409) alert('No scan running');
|
||||
}
|
||||
|
||||
// ── Modals ──
|
||||
function closeModals() {
|
||||
document.getElementById('modalOverlay').classList.remove('visible');
|
||||
document.getElementById('credentialsModal').style.display = 'none';
|
||||
document.getElementById('testConnModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function openCredentialsModal() {
|
||||
document.getElementById('modalOverlay').classList.add('visible');
|
||||
document.getElementById('credentialsModal').style.display = 'block';
|
||||
const msg = document.getElementById('credMsg');
|
||||
msg.textContent = '';
|
||||
msg.className = 'modal-msg';
|
||||
document.getElementById('credPassword').value = '';
|
||||
const res = await fetch('/api/credentials');
|
||||
const d = await res.json();
|
||||
document.getElementById('credUsername').value = d.username || '';
|
||||
document.getElementById('credPasswordHint').textContent = d.password_set ? '(currently set — leave blank to keep it)' : '(not set)';
|
||||
}
|
||||
|
||||
async function saveCredentials() {
|
||||
const username = document.getElementById('credUsername').value.trim();
|
||||
const password = document.getElementById('credPassword').value;
|
||||
const msg = document.getElementById('credMsg');
|
||||
if (!username) {
|
||||
msg.textContent = 'Username is required';
|
||||
msg.className = 'modal-msg fail';
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.error) {
|
||||
msg.textContent = d.error;
|
||||
msg.className = 'modal-msg fail';
|
||||
return;
|
||||
}
|
||||
document.getElementById('credPassword').value = '';
|
||||
document.getElementById('credPasswordHint').textContent = d.password_set ? '(currently set — leave blank to keep it)' : '(not set)';
|
||||
msg.textContent = 'Saved.';
|
||||
msg.className = 'modal-msg ok';
|
||||
}
|
||||
|
||||
async function openTestConnModal() {
|
||||
document.getElementById('modalOverlay').classList.add('visible');
|
||||
document.getElementById('testConnModal').style.display = 'block';
|
||||
const result = document.getElementById('testConnResult');
|
||||
result.textContent = '';
|
||||
result.className = 'modal-msg';
|
||||
const sel = document.getElementById('testConnSelect');
|
||||
sel.innerHTML = '<option>Loading…</option>';
|
||||
try {
|
||||
const res = await fetch('/api/switches/inventory');
|
||||
const list = await res.json();
|
||||
if (list.error) { sel.innerHTML = `<option value="">${list.error}</option>`; return; }
|
||||
if (!list.length) { sel.innerHTML = '<option value="">No active switches found</option>'; return; }
|
||||
sel.innerHTML = list.map(s =>
|
||||
`<option value="${s.ip}" data-manufacturer="${s.manufacturer || ''}">${s.hostname || s.ip} (${s.ip})${s.dept ? ' — ' + s.dept : ''}</option>`
|
||||
).join('');
|
||||
} catch (e) {
|
||||
sel.innerHTML = '<option value="">Failed to load switches</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function runTestConnection() {
|
||||
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 ip = opt.value;
|
||||
const manufacturer = opt.getAttribute('data-manufacturer') || '';
|
||||
const btn = document.getElementById('testConnRunBtn');
|
||||
btn.disabled = true;
|
||||
result.className = 'modal-msg';
|
||||
result.textContent = `Connecting to ${ip}…`;
|
||||
try {
|
||||
const res = await fetch('/api/test-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip, manufacturer }),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.success) {
|
||||
result.className = 'modal-msg ok';
|
||||
result.textContent = `✓ ${d.hostname} [${d.vendor}]\n${d.neighbors.length} LLDP neighbor(s)\nFirmware: ${d.firmware || 'n/a'}`;
|
||||
} else {
|
||||
result.className = 'modal-msg fail';
|
||||
result.textContent = `✗ ${d.error || 'Unknown error'}`;
|
||||
}
|
||||
} catch (e) {
|
||||
result.className = 'modal-msg fail';
|
||||
result.textContent = `✗ Request failed: ${e}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollInterval) return;
|
||||
pollInterval = setInterval(pollStatus, 1500);
|
||||
@@ -947,7 +1199,10 @@ async function pollStatus() {
|
||||
scanBtn.disabled = true;
|
||||
document.getElementById('scanElecBtn').disabled = true;
|
||||
document.getElementById('scanGwBtn').disabled = true;
|
||||
document.getElementById('scanSecBtn').disabled = true;
|
||||
document.getElementById('scanAmiBtn').disabled = true;
|
||||
document.getElementById('clearScanBtn').disabled = true;
|
||||
document.getElementById('abortScanBtn').style.display = '';
|
||||
progressWrap.classList.add('visible');
|
||||
statusBar.classList.add('visible');
|
||||
document.getElementById('scanSpinner').style.display = '';
|
||||
@@ -991,7 +1246,12 @@ async function pollStatus() {
|
||||
logBody.scrollTop = logBody.scrollHeight;
|
||||
|
||||
spinner.style.display = 'none';
|
||||
if (s.fail > 0) {
|
||||
if (s.aborted) {
|
||||
statusText.innerHTML =
|
||||
`⚠ Scan aborted — ` +
|
||||
`<span class="ok-count">✓ ${s.ok}</span> ` +
|
||||
`<span class="fail-count">✗ ${s.fail}</span>`;
|
||||
} else if (s.fail > 0) {
|
||||
statusText.innerHTML =
|
||||
`Scan complete — ` +
|
||||
`<span class="ok-count">✓ ${s.ok}</span> ` +
|
||||
@@ -1004,7 +1264,10 @@ async function pollStatus() {
|
||||
scanBtn.disabled = false;
|
||||
document.getElementById('scanElecBtn').disabled = false;
|
||||
document.getElementById('scanGwBtn').disabled = false;
|
||||
document.getElementById('scanSecBtn').disabled = false;
|
||||
document.getElementById('scanAmiBtn').disabled = false;
|
||||
document.getElementById('clearScanBtn').disabled = false;
|
||||
document.getElementById('abortScanBtn').style.display = 'none';
|
||||
progressBar.style.width = '100%';
|
||||
setTimeout(() => {
|
||||
progressWrap.classList.remove('visible');
|
||||
|
||||
@@ -10,21 +10,10 @@ def normalize_mac(mac_str):
|
||||
return '-'.join(clean[i:i+2] for i in range(0, 12, 2))
|
||||
|
||||
|
||||
def shorten_interface(iface):
|
||||
"""GigabitEthernet 1/9 -> Gi1/9, TenGigabitEthernet 1/1 -> Te1/1 etc."""
|
||||
replacements = [
|
||||
(r'GigabitEthernet\s*', 'Gi'),
|
||||
(r'TenGigabitEthernet\s*', 'Te'),
|
||||
(r'TwentyFiveGigE\s*', 'Twe'),
|
||||
(r'FortyGigabitEthernet\s*', 'Fo'),
|
||||
(r'HundredGigE\s*', 'Hu'),
|
||||
(r'FastEthernet\s*', 'Fa'),
|
||||
(r'Ethernet\s*', 'Eth'),
|
||||
(r'mgmt\s*', 'mgmt'),
|
||||
]
|
||||
for pattern, short in replacements:
|
||||
iface = re.sub(pattern, short, iface, flags=re.IGNORECASE)
|
||||
return iface.strip()
|
||||
def port_number(iface):
|
||||
"""Reduce any interface label to its trailing port number: 'GigabitEthernet1/9' -> '9', 'E14' -> '14', '9' -> '9'."""
|
||||
m = re.search(r'(\d+)$', (iface or '').strip())
|
||||
return m.group(1) if m else (iface or '').strip()
|
||||
|
||||
|
||||
def parse_lldp_neighbors(raw_output, local_chassis_id, local_hostname, local_mgmt_ip):
|
||||
@@ -53,23 +42,30 @@ def parse_lldp_neighbors(raw_output, local_chassis_id, local_hostname, local_mgm
|
||||
m = re.search(pattern, text, re.IGNORECASE)
|
||||
return m.group(1).strip() if m else default
|
||||
|
||||
neighbor['local_port'] = shorten_interface(extract(r'Local Interface\s*:\s*(.+)', block))
|
||||
neighbor['chassis_id'] = extract(r'Chassis ID\s*:\s*(.+)', block)
|
||||
neighbor['port_id'] = extract(r'Port ID\s*:\s*(.+)', block)
|
||||
neighbor['port_desc'] = shorten_interface(extract(r'Port Description\s*:\s*(.+)', block))
|
||||
neighbor['system_name'] = extract(r'System Name\s*:\s*(.+)', block)
|
||||
neighbor['system_desc'] = extract(r'System Description\s*:\s*(.+)', block)
|
||||
# [ \t]* (not \s*) after the colon — a blank field followed immediately by
|
||||
# the next field line must not let the match bleed across the newline into it.
|
||||
neighbor['local_port'] = port_number(extract(r'Local Interface\s*:[ \t]*(.+)', block))
|
||||
neighbor['chassis_id'] = extract(r'Chassis ID\s*:[ \t]*(.+)', block)
|
||||
neighbor['port_id'] = extract(r'Port ID\s*:[ \t]*(.+)', block)
|
||||
neighbor['port_desc'] = port_number(extract(r'Port Description\s*:[ \t]*(.+)', block))
|
||||
neighbor['system_name'] = extract(r'System Name\s*:[ \t]*(.+)', block)
|
||||
neighbor['system_desc'] = extract(r'System Description\s*:[ \t]*(.+)', block)
|
||||
# FS switches report Management Address as MAC (e.g. '64-9D-99-AA-50-B0 (Other)')
|
||||
# or as IP (e.g. '10.214.0.192'). Extract only valid IPv4.
|
||||
raw_mgmt = extract(r'Management Address\s*:\s*([\d\.A-Fa-f\-:]+)', block)
|
||||
ipv4_match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', raw_mgmt)
|
||||
neighbor['mgmt_ip'] = ipv4_match.group(1) if ipv4_match else ''
|
||||
neighbor['capabilities'] = extract(r'System Capabilities\s*:\s*(.+)', block)
|
||||
neighbor['capabilities'] = extract(r'System Capabilities\s*:[ \t]*(.+)', block)
|
||||
|
||||
# Only include bridge/switch neighbors (skip phones, APs listed as endpoints)
|
||||
if neighbor['chassis_id'] and neighbor['system_name']:
|
||||
# Use port_desc as remote port if available, fallback to port_id
|
||||
neighbor['remote_port'] = neighbor['port_desc'] if neighbor['port_desc'] else neighbor['port_id']
|
||||
# Only include bridge/router neighbors — skip phones, APs, print servers,
|
||||
# cameras, etc. that show up as "Station Only" or similar endpoint-only capabilities.
|
||||
is_bridge_or_router = re.search(r'Bridge|Router', neighbor['capabilities'], re.IGNORECASE)
|
||||
if neighbor['chassis_id'] and neighbor['system_name'] and is_bridge_or_router:
|
||||
# Port ID is the mandatory, standardized LLDP field — prefer it. Port
|
||||
# Description is optional free text; some vendors (e.g. Dell OS10) put
|
||||
# human documentation there ("SUB-FAIR-SW01 9") that isn't a real port
|
||||
# name and can collide across interfaces, so it's only a fallback.
|
||||
neighbor['remote_port'] = port_number(neighbor['port_id']) if neighbor['port_id'] else neighbor['port_desc']
|
||||
neighbors.append(neighbor)
|
||||
|
||||
return neighbors
|
||||
@@ -100,6 +96,14 @@ def parse_aruba_procurve_local(raw_output):
|
||||
'system_desc': system_desc, 'mgmt_ip': mgmt_ip}
|
||||
|
||||
|
||||
|
||||
# Older HP ProCurve firmware (e.g. 5412zl / J8698A) prints ChassisId as six
|
||||
# space-separated hex octets ('64 9d 99 b8 f8 d0') instead of the single
|
||||
# colon/dash-joined token newer Aruba switches use — match that form first,
|
||||
# since a naive whitespace split misreads the octets as separate columns.
|
||||
_SPACED_MAC_RE = re.compile(r'^((?:[0-9A-Fa-f]{2}\s+){5}[0-9A-Fa-f]{2})\s+(.*)$')
|
||||
|
||||
|
||||
def parse_aruba_procurve_neighbors(raw_output):
|
||||
"""
|
||||
Parse 'show lldp info remote-device' tabular output from HP/Aruba switch.
|
||||
@@ -116,14 +120,26 @@ def parse_aruba_procurve_neighbors(raw_output):
|
||||
continue
|
||||
|
||||
left, right = line.split('|', 1)
|
||||
# Not passed through port_number(): Aruba port names encode the module/slot
|
||||
# (e.g. 'E14', 'F24') which is meaningful and must not be reduced to bare digits.
|
||||
local_port = left.strip()
|
||||
parts = right.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
right = right.strip()
|
||||
|
||||
chassis_id = normalize_mac(parts[0])
|
||||
port_id = parts[1]
|
||||
sys_name = parts[3]
|
||||
m = _SPACED_MAC_RE.match(right)
|
||||
if m:
|
||||
chassis_id = normalize_mac(m.group(1))
|
||||
rest = m.group(2).split()
|
||||
else:
|
||||
parts = right.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
chassis_id = normalize_mac(parts[0])
|
||||
rest = parts[1:]
|
||||
|
||||
if len(rest) < 3:
|
||||
continue
|
||||
port_id = rest[0]
|
||||
sys_name = rest[-1]
|
||||
|
||||
neighbors.append({
|
||||
'local_port': local_port,
|
||||
|
||||
+34
-1
@@ -1,5 +1,6 @@
|
||||
# scanner.py - Orchestrates the full scan pipeline
|
||||
import logging
|
||||
import threading
|
||||
from nocodb_client import get_switches
|
||||
from ssh_client import scan_all_switches
|
||||
from db import (
|
||||
@@ -24,8 +25,19 @@ scan_state = {
|
||||
"log_lines": [],
|
||||
"last_scan": None,
|
||||
"dept_filter": None, # None = all, "ELEC" or "GW" = dept-only
|
||||
"aborted": False,
|
||||
}
|
||||
|
||||
_abort_event = threading.Event()
|
||||
|
||||
|
||||
def request_abort():
|
||||
"""Signal the in-progress scan to stop after currently in-flight switches finish."""
|
||||
if scan_state["running"]:
|
||||
_abort_event.set()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
|
||||
"""
|
||||
@@ -38,6 +50,8 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
|
||||
logger.warning("Scan already running, skipping.")
|
||||
return
|
||||
|
||||
_abort_event.clear()
|
||||
|
||||
# Fetch switch list from NocoDB (or fallback)
|
||||
switches = get_switches(dept=dept)
|
||||
|
||||
@@ -59,6 +73,7 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
|
||||
"errors": [],
|
||||
"log_lines": [],
|
||||
"dept_filter": dept,
|
||||
"aborted": False,
|
||||
})
|
||||
|
||||
scan_id = log_scan_start()
|
||||
@@ -89,6 +104,7 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
|
||||
description=result.get("description", ""),
|
||||
firmware=result.get("firmware", ""),
|
||||
vendor=result.get("vendor", ""),
|
||||
serial=result.get("serial", ""),
|
||||
)
|
||||
|
||||
for neighbor in result["neighbors"]:
|
||||
@@ -119,7 +135,16 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
|
||||
"text": f"✗ {ip} — {error_short}",
|
||||
})
|
||||
|
||||
scan_all_switches(switches, progress_callback=on_progress, max_workers=workers, login_delay=login_delay)
|
||||
scan_all_switches(switches, progress_callback=on_progress, max_workers=workers,
|
||||
login_delay=login_delay, abort_event=_abort_event)
|
||||
|
||||
if _abort_event.is_set():
|
||||
scan_state["aborted"] = True
|
||||
from datetime import datetime
|
||||
scan_state["log_lines"].append({
|
||||
"ts": datetime.now().strftime("%H:%M:%S"), "ok": False,
|
||||
"text": "⚠ Scan aborted by user",
|
||||
})
|
||||
|
||||
try:
|
||||
merge_duplicate_switches()
|
||||
@@ -141,3 +166,11 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
|
||||
scan_state["dept_filter"] = None
|
||||
|
||||
logger.info(f"Scan complete. OK: {scan_state['ok']}, Failed: {scan_state['fail']}")
|
||||
|
||||
|
||||
def test_single_switch(ip: str, manufacturer: str = "", login_delay: int = 1) -> dict:
|
||||
"""Test SSH connectivity/LLDP query against a single switch, independent of the main scan.
|
||||
Does not touch scan_state or the database — just returns the raw connect_and_query result."""
|
||||
entry = {"ip": ip, "manufacturer": manufacturer}
|
||||
results = scan_all_switches([entry], max_workers=1, login_delay=login_delay)
|
||||
return results[0] if results else {"success": False, "ip": ip, "error": "No result"}
|
||||
|
||||
+44
-12
@@ -7,7 +7,8 @@ import socket
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from functools import partial
|
||||
from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
|
||||
from config import SSH_USERNAME, SSH_PASSWORD, SSH_PORT, SSH_TIMEOUT, DEVICE_TYPE
|
||||
from config import SSH_PORT, SSH_TIMEOUT, DEVICE_TYPE
|
||||
import db
|
||||
from parser import (parse_lldp_neighbors, parse_mgmt_ip_from_interfaces,
|
||||
parse_aruba_procurve_local, parse_aruba_procurve_neighbors,
|
||||
normalize_mac)
|
||||
@@ -35,12 +36,18 @@ def _device_type_for(manufacturer: str) -> str:
|
||||
return DEVICE_TYPE
|
||||
|
||||
|
||||
def _get_credentials():
|
||||
"""SSH credentials, editable at runtime via /api/credentials (stored in the settings table)."""
|
||||
return db.get_setting("ssh_username"), db.get_setting("ssh_password")
|
||||
|
||||
|
||||
def connect_and_query(ip, login_delay=3, device_type=None):
|
||||
username, password = _get_credentials()
|
||||
device = {
|
||||
"device_type": device_type or DEVICE_TYPE,
|
||||
"host": ip,
|
||||
"username": SSH_USERNAME,
|
||||
"password": SSH_PASSWORD,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"port": SSH_PORT,
|
||||
"timeout": SSH_TIMEOUT,
|
||||
"global_delay_factor": 2,
|
||||
@@ -78,9 +85,9 @@ def connect_and_query(ip, login_delay=3, device_type=None):
|
||||
vendor = _detect_vendor(version_output)
|
||||
|
||||
if vendor == 'aruba_procurve':
|
||||
chassis_id, mgmt_ip, model, firmware, neighbors = _query_aruba(conn, version_output, ip)
|
||||
chassis_id, mgmt_ip, model, firmware, serial, neighbors = _query_aruba(conn, version_output, ip)
|
||||
else:
|
||||
chassis_id, mgmt_ip, model, firmware, neighbors = _query_fs(conn, version_output, ip)
|
||||
chassis_id, mgmt_ip, model, firmware, serial, neighbors = _query_fs(conn, version_output, ip)
|
||||
finally:
|
||||
conn.disconnect()
|
||||
|
||||
@@ -94,6 +101,7 @@ def connect_and_query(ip, login_delay=3, device_type=None):
|
||||
"mgmt_ip": mgmt_ip,
|
||||
"description": model,
|
||||
"firmware": firmware,
|
||||
"serial": serial,
|
||||
"vendor": vendor,
|
||||
"neighbors": neighbors,
|
||||
}
|
||||
@@ -110,7 +118,7 @@ def connect_and_query(ip, login_delay=3, device_type=None):
|
||||
|
||||
|
||||
def _query_fs(conn, version_output, ip):
|
||||
"""Run FS/IES switch-specific commands and return (chassis_id, mgmt_ip, model, firmware, neighbors)."""
|
||||
"""Run FS/IES switch-specific commands and return (chassis_id, mgmt_ip, model, firmware, serial, neighbors)."""
|
||||
intf_output = conn.send_command("show ip interface brief", read_timeout=30)
|
||||
local_info_output = conn.send_command("show lldp local-information", read_timeout=30)
|
||||
lldp_output = conn.send_command("show lldp neighbors", read_timeout=30)
|
||||
@@ -123,24 +131,27 @@ def _query_fs(conn, version_output, ip):
|
||||
|
||||
model = _fs_model(version_output) or _extract_system_desc(local_info_output)
|
||||
firmware = _fs_firmware(version_output)
|
||||
serial = _extract_serial(version_output)
|
||||
neighbors = parse_lldp_neighbors(lldp_output, chassis_id, '', mgmt_ip)
|
||||
|
||||
return chassis_id, mgmt_ip, model, firmware, neighbors
|
||||
return chassis_id, mgmt_ip, model, firmware, serial, neighbors
|
||||
|
||||
|
||||
def _query_aruba(conn, version_output, ip):
|
||||
"""Run HP/Aruba ProCurve-specific commands and return (chassis_id, mgmt_ip, model, firmware, neighbors)."""
|
||||
"""Run HP/Aruba ProCurve-specific commands and return (chassis_id, mgmt_ip, model, firmware, serial, neighbors)."""
|
||||
local_output = conn.send_command("show lldp info local-device", read_timeout=30)
|
||||
remote_output = conn.send_command("show lldp info remote-device", read_timeout=30)
|
||||
sysinfo_output = conn.send_command("show system information", read_timeout=30)
|
||||
|
||||
local_info = parse_aruba_procurve_local(local_output)
|
||||
chassis_id = local_info['chassis_id'] or _extract_mac_from_version(version_output) or ip
|
||||
mgmt_ip = local_info['mgmt_ip'] or ip
|
||||
model = _aruba_model(local_info.get('system_desc', ''))
|
||||
firmware = _aruba_firmware(version_output)
|
||||
serial = _extract_serial(sysinfo_output) or _extract_serial(version_output)
|
||||
neighbors = parse_aruba_procurve_neighbors(remote_output)
|
||||
|
||||
return chassis_id, mgmt_ip, model, firmware, neighbors
|
||||
return chassis_id, mgmt_ip, model, firmware, serial, neighbors
|
||||
|
||||
|
||||
# ── Vendor detection ──────────────────────────────────────────────────────────
|
||||
@@ -165,6 +176,16 @@ def _extract_mac_from_version(output):
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def _extract_serial(output):
|
||||
"""Serial number field — covers 'Serial Number:'/'Serial No:'/'Serial:' spellings
|
||||
plus FS/IES's 'SN :' field (confirmed from a live rec-10016-sw01 'show version')."""
|
||||
m = re.search(r'Serial\s*(?:No\.?|Number)?\s*:\s*(\S+)', output, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
m = re.search(r'^\s*SN\s*:\s*(\S+)', output, re.IGNORECASE | re.MULTILINE)
|
||||
return m.group(1).strip() if m else ''
|
||||
|
||||
|
||||
def _fs_model(output):
|
||||
m = re.search(r'(?:Model Name|Product)\s*:\s*([\w\-]+)', output, re.IGNORECASE)
|
||||
return f"FS {m.group(1)}" if m else ''
|
||||
@@ -203,8 +224,10 @@ def _aruba_firmware(version_output):
|
||||
|
||||
# ── Scan orchestration ────────────────────────────────────────────────────────
|
||||
|
||||
def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_delay=3):
|
||||
"""switch_list: list of IP strings or dicts with 'ip' and optional 'manufacturer'."""
|
||||
def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_delay=3, abort_event=None):
|
||||
"""switch_list: list of IP strings or dicts with 'ip' and optional 'manufacturer'.
|
||||
abort_event: optional threading.Event — when set, stops queueing new work and
|
||||
returns without waiting on switches still in flight."""
|
||||
results = []
|
||||
total = len(switch_list)
|
||||
done = 0
|
||||
@@ -217,7 +240,8 @@ def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_
|
||||
ip, dt = entry, None
|
||||
return ip, executor.submit(connect_and_query, ip, login_delay=login_delay, device_type=dt)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
try:
|
||||
future_to_ip = {}
|
||||
for entry in switch_list:
|
||||
ip, fut = _submit(entry)
|
||||
@@ -237,4 +261,12 @@ def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_
|
||||
if progress_callback:
|
||||
progress_callback(done, total, ip, result)
|
||||
|
||||
if abort_event is not None and abort_event.is_set():
|
||||
logger.warning("Scan aborted — stopping after in-flight switches finish.")
|
||||
break
|
||||
finally:
|
||||
# wait=False: don't block on switches still logging in; they'll finish
|
||||
# in the background and their results are discarded.
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
return results
|
||||
|
||||
+45
-59
@@ -16,13 +16,8 @@ logger = logging.getLogger(__name__)
|
||||
# --- Configuration ---
|
||||
DB_PATH = "/opt/lldp-mapper/data/network.db"
|
||||
|
||||
NETBOX_URL = "http://192.168.16.130:8001"
|
||||
NETBOX_TOKEN = "nbt_T6aq9XpwNFQG.HtZziXuSATgabbeagWKk3vEhc2Ask1EMV210PWMM"
|
||||
|
||||
SITE_NAME = "Field Sites"
|
||||
DEVICE_ROLE = "Access Switch"
|
||||
MANUFACTURER = "FS"
|
||||
DEVICE_TYPE = "FS Switch"
|
||||
NETBOX_URL = "http://192.168.16.137:8000"
|
||||
NETBOX_TOKEN = "nbt_LYkI6iSsflIU.9N4ziQxpbrV1AGPmpk0fwhfhJOSrSr0nGXEF4Ze2"
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
@@ -72,54 +67,21 @@ def load_db():
|
||||
|
||||
|
||||
# --- NetBox Sync ---
|
||||
#
|
||||
# This NetBox instance is hand-curated (real sites, device types, roles) —
|
||||
# this script never creates devices. It only looks up devices that already
|
||||
# exist by hostname and syncs their interfaces/cables/IP. A switch lldp-mapper
|
||||
# knows about but that has no matching device in NetBox is skipped with a
|
||||
# warning so it can be added properly (correct site/manufacturer) by hand.
|
||||
|
||||
def ensure_site():
|
||||
return nb_get_or_create(
|
||||
"dcim/sites/",
|
||||
{"name": SITE_NAME},
|
||||
{"name": SITE_NAME, "slug": SITE_NAME.lower().replace(" ", "-")}
|
||||
)
|
||||
|
||||
|
||||
def ensure_manufacturer():
|
||||
return nb_get_or_create(
|
||||
"dcim/manufacturers/",
|
||||
{"name": MANUFACTURER},
|
||||
{"name": MANUFACTURER, "slug": MANUFACTURER.lower()}
|
||||
)
|
||||
|
||||
|
||||
def ensure_device_type(manufacturer_id):
|
||||
return nb_get_or_create(
|
||||
"dcim/device-types/",
|
||||
{"slug": "fs-switch"},
|
||||
{"model": DEVICE_TYPE, "slug": "fs-switch", "manufacturer": manufacturer_id}
|
||||
)
|
||||
|
||||
|
||||
def ensure_device_role():
|
||||
return nb_get_or_create(
|
||||
"dcim/device-roles/",
|
||||
{"name": DEVICE_ROLE},
|
||||
{"name": DEVICE_ROLE, "slug": "access-switch", "color": "2196f3"}
|
||||
)
|
||||
|
||||
|
||||
def ensure_device(switch, site_id, device_type_id, role_id):
|
||||
def ensure_device(switch):
|
||||
hostname = switch["hostname"] or switch["mgmt_ip"]
|
||||
results = nb_get("dcim/devices/", params={"name": hostname}).get("results", [])
|
||||
if results:
|
||||
logger.info(f" Device exists: {hostname}")
|
||||
return results[0]
|
||||
logger.info(f" Creating device: {hostname}")
|
||||
return nb_post("dcim/devices/", {
|
||||
"name": hostname,
|
||||
"site": site_id,
|
||||
"device_type": device_type_id,
|
||||
"role": role_id,
|
||||
"status": "active",
|
||||
"comments": f"Chassis ID: {switch['chassis_id']}\nDiscovered by lldp-mapper",
|
||||
})
|
||||
logger.warning(f" Skipping {hostname} — no matching device in NetBox (add it manually first)")
|
||||
return None
|
||||
|
||||
|
||||
def ensure_interface(device_id, port_name):
|
||||
@@ -136,6 +98,35 @@ def ensure_interface(device_id, port_name):
|
||||
})
|
||||
|
||||
|
||||
def ensure_serial(switch, device):
|
||||
serial = switch.get("serial") or ""
|
||||
if not serial or device.get("serial") == serial:
|
||||
return
|
||||
logger.info(f" Updating serial: {serial}")
|
||||
requests.patch(
|
||||
f"{NETBOX_URL}/api/dcim/devices/{device['id']}/",
|
||||
headers=NB_HEADERS,
|
||||
json={"serial": serial}
|
||||
)
|
||||
|
||||
|
||||
def ensure_mac_comment(switch, device):
|
||||
mac = switch.get("chassis_id") or ""
|
||||
if not mac or "-" not in mac: # only real MACs, not IP-based chassis IDs
|
||||
return
|
||||
line = f"Chassis MAC: {mac}"
|
||||
comments = device.get("comments") or ""
|
||||
if line in comments:
|
||||
return
|
||||
logger.info(f" Recording chassis MAC in comments: {mac}")
|
||||
new_comments = f"{comments}\n{line}".strip() if comments else line
|
||||
requests.patch(
|
||||
f"{NETBOX_URL}/api/dcim/devices/{device['id']}/",
|
||||
headers=NB_HEADERS,
|
||||
json={"comments": new_comments}
|
||||
)
|
||||
|
||||
|
||||
def ensure_ip(switch, device_id):
|
||||
if not switch.get("mgmt_ip"):
|
||||
return
|
||||
@@ -187,29 +178,24 @@ def ensure_cable(device_map, link):
|
||||
"a_terminations": [{"object_type": "dcim.interface", "object_id": iface_a["id"]}],
|
||||
"b_terminations": [{"object_type": "dcim.interface", "object_id": iface_b["id"]}],
|
||||
"status": "connected",
|
||||
"label": f"{hn_a}→{hn_b}",
|
||||
"type": "smf-os2",
|
||||
})
|
||||
|
||||
|
||||
def sync_netbox(switches, links):
|
||||
logger.info("=== Syncing to NetBox ===")
|
||||
|
||||
site = ensure_site()
|
||||
manufacturer = ensure_manufacturer()
|
||||
device_type = ensure_device_type(manufacturer["id"])
|
||||
role = ensure_device_role()
|
||||
|
||||
site_id = site["id"]
|
||||
device_type_id = device_type["id"]
|
||||
role_id = role["id"]
|
||||
|
||||
device_map = {}
|
||||
|
||||
for sw in switches:
|
||||
hostname = sw["hostname"] or sw["mgmt_ip"]
|
||||
logger.info(f"Processing device: {hostname}")
|
||||
device = ensure_device(sw, site_id, device_type_id, role_id)
|
||||
device = ensure_device(sw)
|
||||
if device:
|
||||
device_map[hostname] = device
|
||||
ensure_serial(sw, device)
|
||||
ensure_mac_comment(sw, device)
|
||||
ensure_ip(sw, device["id"])
|
||||
|
||||
logger.info(f"Devices synced: {len(device_map)}")
|
||||
|
||||
Reference in New Issue
Block a user