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 logging
|
||||||
import threading
|
import threading
|
||||||
import os
|
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
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
import db
|
import db
|
||||||
from db import save_node_positions, get_node_positions, clear_node_positions
|
from db import save_node_positions, get_node_positions, clear_node_positions
|
||||||
import scanner
|
import scanner
|
||||||
|
from nocodb_client import get_switches
|
||||||
from config import EXPORTS_DIR
|
from config import EXPORTS_DIR
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
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"})
|
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")
|
@app.route("/api/status")
|
||||||
def api_status():
|
def api_status():
|
||||||
state = dict(scanner.scan_state)
|
state = dict(scanner.scan_state)
|
||||||
@@ -124,6 +156,26 @@ def api_switches():
|
|||||||
return jsonify(db.get_all_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")
|
@app.route("/api/links")
|
||||||
def api_links():
|
def api_links():
|
||||||
return jsonify(db.get_all_links())
|
return jsonify(db.get_all_links())
|
||||||
@@ -145,6 +197,7 @@ def api_topology():
|
|||||||
"description": sw.get("description", ""),
|
"description": sw.get("description", ""),
|
||||||
"firmware": sw.get("firmware", ""),
|
"firmware": sw.get("firmware", ""),
|
||||||
"vendor": sw.get("vendor", ""),
|
"vendor": sw.get("vendor", ""),
|
||||||
|
"serial": sw.get("serial", ""),
|
||||||
"chassis_id": chassis_id,
|
"chassis_id": chassis_id,
|
||||||
"last_seen": sw.get("last_seen", ""),
|
"last_seen": sw.get("last_seen", ""),
|
||||||
}
|
}
|
||||||
@@ -170,6 +223,31 @@ def api_topology():
|
|||||||
return jsonify({"nodes": nodes, "edges": edges})
|
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"])
|
@app.route("/api/settings", methods=["POST"])
|
||||||
def api_settings():
|
def api_settings():
|
||||||
data = request.json
|
data = request.json
|
||||||
@@ -229,6 +307,30 @@ def api_clear_stale_switches():
|
|||||||
deleted = db.clear_stale_switches(since)
|
deleted = db.clear_stale_switches(since)
|
||||||
return jsonify({"deleted": deleted})
|
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")
|
@app.route("/api/export/csv")
|
||||||
def api_export_csv():
|
def api_export_csv():
|
||||||
path = os.path.join(EXPORTS_DIR, "topology.csv")
|
path = os.path.join(EXPORTS_DIR, "topology.csv")
|
||||||
@@ -255,4 +357,4 @@ def api_export_png():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
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 re
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from config import DB_PATH
|
from config import DB_PATH, SSH_USERNAME, SSH_PASSWORD
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ def init_db():
|
|||||||
last_seen TEXT
|
last_seen TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
# Migrate existing DBs that predate firmware/vendor columns
|
# Migrate existing DBs that predate firmware/vendor/serial columns
|
||||||
for col, default in [('firmware', ''), ('vendor', '')]:
|
for col, default in [('firmware', ''), ('vendor', ''), ('serial', '')]:
|
||||||
try:
|
try:
|
||||||
c.execute(f"ALTER TABLE switches ADD COLUMN {col} TEXT DEFAULT '{default}'")
|
c.execute(f"ALTER TABLE switches ADD COLUMN {col} TEXT DEFAULT '{default}'")
|
||||||
except Exception:
|
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_enabled', 'false')")
|
||||||
c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_interval', '60')")
|
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(
|
c.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS node_positions "
|
"CREATE TABLE IF NOT EXISTS node_positions "
|
||||||
@@ -78,11 +82,11 @@ def init_db():
|
|||||||
conn.close()
|
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 = get_conn()
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO switches (chassis_id, hostname, mgmt_ip, description, firmware, vendor, last_seen)
|
INSERT INTO switches (chassis_id, hostname, mgmt_ip, description, firmware, vendor, serial, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
ON CONFLICT(chassis_id) DO UPDATE SET
|
ON CONFLICT(chassis_id) DO UPDATE SET
|
||||||
hostname = excluded.hostname,
|
hostname = excluded.hostname,
|
||||||
mgmt_ip = CASE WHEN excluded.mgmt_ip != '' AND excluded.mgmt_ip LIKE '%.%.%.%'
|
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,
|
description = CASE WHEN excluded.description != '' THEN excluded.description ELSE description END,
|
||||||
firmware = CASE WHEN excluded.firmware != '' THEN excluded.firmware ELSE firmware END,
|
firmware = CASE WHEN excluded.firmware != '' THEN excluded.firmware ELSE firmware END,
|
||||||
vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor 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
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -103,10 +108,23 @@ def _port_num(port):
|
|||||||
|
|
||||||
|
|
||||||
def upsert_link(chassis_a, port_a, chassis_b, port_b):
|
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
|
# Normalize order so A→B and B→A are treated as the same link
|
||||||
if chassis_a > chassis_b:
|
if chassis_a > chassis_b:
|
||||||
chassis_a, chassis_b = chassis_b, chassis_a
|
chassis_a, chassis_b = chassis_b, chassis_a
|
||||||
port_a, port_b = port_b, port_a
|
port_a, port_b = port_b, port_a
|
||||||
|
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
# Exact match first
|
# Exact match first
|
||||||
existing = conn.execute("""
|
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
|
# Fuzzy match: same chassis pair, same trailing port numbers
|
||||||
# handles 'Gi1/9' vs '9' reported by different vendors for the same cable
|
# handles 'Gi1/9' vs '9' reported by different vendors for the same cable
|
||||||
rows = conn.execute("""
|
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()
|
""", (chassis_a, chassis_b)).fetchall()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if _port_num(port_a) == _port_num(r[0]) and _port_num(port_b) == _port_num(r[1]):
|
if _port_num(port_a) == _port_num(r[1]) and _port_num(port_b) == _port_num(r[2]):
|
||||||
existing = True
|
existing = r
|
||||||
break
|
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("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO links (chassis_a, port_a, chassis_b, port_b)
|
INSERT OR IGNORE INTO links (chassis_a, port_a, chassis_b, port_b)
|
||||||
VALUES (?, ?, ?, ?)
|
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-elec:hover { background: #e05a00; }
|
||||||
.btn-gw { background: #1a5c2a; color: #fff; }
|
.btn-gw { background: #1a5c2a; color: #fff; }
|
||||||
.btn-gw:hover { background: #247a39; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<span class="setting-val" id="delayVal">3s</span>
|
||||||
</div>
|
</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()">
|
<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">
|
<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"/>
|
<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>
|
<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
|
Scan GW
|
||||||
</button>
|
</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()">
|
<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"/>
|
||||||
</svg>
|
</svg>
|
||||||
Clear & Rescan
|
Clear & Rescan
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</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('csv')">⬇ CSV</button>
|
||||||
<button class="btn-sm" onclick="exportFile('mermaid')">⬇ Mermaid</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('png')">⬇ PNG</button>
|
||||||
|
<button class="btn-sm" onclick="exportFile('serials')" title="Hostname/IP/Model/Serial for pasting into NocoDB">⬇ Serials</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 class="scan-log-body" id="scanLogBody"></div>
|
||||||
</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>
|
<script>
|
||||||
/**
|
/**
|
||||||
* Copyright (c) 2016-2025, The Cytoscape Consortium.
|
* Copyright (c) 2016-2025, The Cytoscape Consortium.
|
||||||
@@ -827,6 +963,10 @@ function showNodeDetail(data) {
|
|||||||
<span class="detail-key">Firmware</span>
|
<span class="detail-key">Firmware</span>
|
||||||
<span class="detail-val">${data.firmware || '—'}</span>
|
<span class="detail-val">${data.firmware || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-key">Serial</span>
|
||||||
|
<span class="detail-val">${data.serial || '—'}</span>
|
||||||
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-key">Last Seen</span>
|
<span class="detail-key">Last Seen</span>
|
||||||
<span class="detail-val">${data.last_seen || '—'}</span>
|
<span class="detail-val">${data.last_seen || '—'}</span>
|
||||||
@@ -916,6 +1056,8 @@ function scanSettings() {
|
|||||||
async function triggerScan(dept) {
|
async function triggerScan(dept) {
|
||||||
const url = dept === 'elec' ? '/api/scan/elec'
|
const url = dept === 'elec' ? '/api/scan/elec'
|
||||||
: dept === 'gw' ? '/api/scan/gw'
|
: dept === 'gw' ? '/api/scan/gw'
|
||||||
|
: dept === 'sec' ? '/api/scan/sec'
|
||||||
|
: dept === 'ami' ? '/api/scan/ami'
|
||||||
: '/api/scan';
|
: '/api/scan';
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -926,6 +1068,116 @@ async function triggerScan(dept) {
|
|||||||
startPolling();
|
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() {
|
function startPolling() {
|
||||||
if (pollInterval) return;
|
if (pollInterval) return;
|
||||||
pollInterval = setInterval(pollStatus, 1500);
|
pollInterval = setInterval(pollStatus, 1500);
|
||||||
@@ -947,7 +1199,10 @@ async function pollStatus() {
|
|||||||
scanBtn.disabled = true;
|
scanBtn.disabled = true;
|
||||||
document.getElementById('scanElecBtn').disabled = true;
|
document.getElementById('scanElecBtn').disabled = true;
|
||||||
document.getElementById('scanGwBtn').disabled = true;
|
document.getElementById('scanGwBtn').disabled = true;
|
||||||
|
document.getElementById('scanSecBtn').disabled = true;
|
||||||
|
document.getElementById('scanAmiBtn').disabled = true;
|
||||||
document.getElementById('clearScanBtn').disabled = true;
|
document.getElementById('clearScanBtn').disabled = true;
|
||||||
|
document.getElementById('abortScanBtn').style.display = '';
|
||||||
progressWrap.classList.add('visible');
|
progressWrap.classList.add('visible');
|
||||||
statusBar.classList.add('visible');
|
statusBar.classList.add('visible');
|
||||||
document.getElementById('scanSpinner').style.display = '';
|
document.getElementById('scanSpinner').style.display = '';
|
||||||
@@ -991,7 +1246,12 @@ async function pollStatus() {
|
|||||||
logBody.scrollTop = logBody.scrollHeight;
|
logBody.scrollTop = logBody.scrollHeight;
|
||||||
|
|
||||||
spinner.style.display = 'none';
|
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 =
|
statusText.innerHTML =
|
||||||
`Scan complete — ` +
|
`Scan complete — ` +
|
||||||
`<span class="ok-count">✓ ${s.ok}</span> ` +
|
`<span class="ok-count">✓ ${s.ok}</span> ` +
|
||||||
@@ -1004,7 +1264,10 @@ async function pollStatus() {
|
|||||||
scanBtn.disabled = false;
|
scanBtn.disabled = false;
|
||||||
document.getElementById('scanElecBtn').disabled = false;
|
document.getElementById('scanElecBtn').disabled = false;
|
||||||
document.getElementById('scanGwBtn').disabled = false;
|
document.getElementById('scanGwBtn').disabled = false;
|
||||||
|
document.getElementById('scanSecBtn').disabled = false;
|
||||||
|
document.getElementById('scanAmiBtn').disabled = false;
|
||||||
document.getElementById('clearScanBtn').disabled = false;
|
document.getElementById('clearScanBtn').disabled = false;
|
||||||
|
document.getElementById('abortScanBtn').style.display = 'none';
|
||||||
progressBar.style.width = '100%';
|
progressBar.style.width = '100%';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
progressWrap.classList.remove('visible');
|
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))
|
return '-'.join(clean[i:i+2] for i in range(0, 12, 2))
|
||||||
|
|
||||||
|
|
||||||
def shorten_interface(iface):
|
def port_number(iface):
|
||||||
"""GigabitEthernet 1/9 -> Gi1/9, TenGigabitEthernet 1/1 -> Te1/1 etc."""
|
"""Reduce any interface label to its trailing port number: 'GigabitEthernet1/9' -> '9', 'E14' -> '14', '9' -> '9'."""
|
||||||
replacements = [
|
m = re.search(r'(\d+)$', (iface or '').strip())
|
||||||
(r'GigabitEthernet\s*', 'Gi'),
|
return m.group(1) if m else (iface or '').strip()
|
||||||
(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 parse_lldp_neighbors(raw_output, local_chassis_id, local_hostname, local_mgmt_ip):
|
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)
|
m = re.search(pattern, text, re.IGNORECASE)
|
||||||
return m.group(1).strip() if m else default
|
return m.group(1).strip() if m else default
|
||||||
|
|
||||||
neighbor['local_port'] = shorten_interface(extract(r'Local Interface\s*:\s*(.+)', block))
|
# [ \t]* (not \s*) after the colon — a blank field followed immediately by
|
||||||
neighbor['chassis_id'] = extract(r'Chassis ID\s*:\s*(.+)', block)
|
# the next field line must not let the match bleed across the newline into it.
|
||||||
neighbor['port_id'] = extract(r'Port ID\s*:\s*(.+)', block)
|
neighbor['local_port'] = port_number(extract(r'Local Interface\s*:[ \t]*(.+)', block))
|
||||||
neighbor['port_desc'] = shorten_interface(extract(r'Port Description\s*:\s*(.+)', block))
|
neighbor['chassis_id'] = extract(r'Chassis ID\s*:[ \t]*(.+)', block)
|
||||||
neighbor['system_name'] = extract(r'System Name\s*:\s*(.+)', block)
|
neighbor['port_id'] = extract(r'Port ID\s*:[ \t]*(.+)', block)
|
||||||
neighbor['system_desc'] = extract(r'System Description\s*:\s*(.+)', 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)')
|
# 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.
|
# 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)
|
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)
|
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['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)
|
# Only include bridge/router neighbors — skip phones, APs, print servers,
|
||||||
if neighbor['chassis_id'] and neighbor['system_name']:
|
# cameras, etc. that show up as "Station Only" or similar endpoint-only capabilities.
|
||||||
# Use port_desc as remote port if available, fallback to port_id
|
is_bridge_or_router = re.search(r'Bridge|Router', neighbor['capabilities'], re.IGNORECASE)
|
||||||
neighbor['remote_port'] = neighbor['port_desc'] if neighbor['port_desc'] else neighbor['port_id']
|
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)
|
neighbors.append(neighbor)
|
||||||
|
|
||||||
return neighbors
|
return neighbors
|
||||||
@@ -100,6 +96,14 @@ def parse_aruba_procurve_local(raw_output):
|
|||||||
'system_desc': system_desc, 'mgmt_ip': mgmt_ip}
|
'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):
|
def parse_aruba_procurve_neighbors(raw_output):
|
||||||
"""
|
"""
|
||||||
Parse 'show lldp info remote-device' tabular output from HP/Aruba switch.
|
Parse 'show lldp info remote-device' tabular output from HP/Aruba switch.
|
||||||
@@ -116,14 +120,26 @@ def parse_aruba_procurve_neighbors(raw_output):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
left, right = line.split('|', 1)
|
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()
|
local_port = left.strip()
|
||||||
parts = right.split()
|
right = right.strip()
|
||||||
if len(parts) < 4:
|
|
||||||
continue
|
|
||||||
|
|
||||||
chassis_id = normalize_mac(parts[0])
|
m = _SPACED_MAC_RE.match(right)
|
||||||
port_id = parts[1]
|
if m:
|
||||||
sys_name = parts[3]
|
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({
|
neighbors.append({
|
||||||
'local_port': local_port,
|
'local_port': local_port,
|
||||||
|
|||||||
+34
-1
@@ -1,5 +1,6 @@
|
|||||||
# scanner.py - Orchestrates the full scan pipeline
|
# scanner.py - Orchestrates the full scan pipeline
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from nocodb_client import get_switches
|
from nocodb_client import get_switches
|
||||||
from ssh_client import scan_all_switches
|
from ssh_client import scan_all_switches
|
||||||
from db import (
|
from db import (
|
||||||
@@ -24,8 +25,19 @@ scan_state = {
|
|||||||
"log_lines": [],
|
"log_lines": [],
|
||||||
"last_scan": None,
|
"last_scan": None,
|
||||||
"dept_filter": None, # None = all, "ELEC" or "GW" = dept-only
|
"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):
|
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.")
|
logger.warning("Scan already running, skipping.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_abort_event.clear()
|
||||||
|
|
||||||
# Fetch switch list from NocoDB (or fallback)
|
# Fetch switch list from NocoDB (or fallback)
|
||||||
switches = get_switches(dept=dept)
|
switches = get_switches(dept=dept)
|
||||||
|
|
||||||
@@ -59,6 +73,7 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
|
|||||||
"errors": [],
|
"errors": [],
|
||||||
"log_lines": [],
|
"log_lines": [],
|
||||||
"dept_filter": dept,
|
"dept_filter": dept,
|
||||||
|
"aborted": False,
|
||||||
})
|
})
|
||||||
|
|
||||||
scan_id = log_scan_start()
|
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", ""),
|
description=result.get("description", ""),
|
||||||
firmware=result.get("firmware", ""),
|
firmware=result.get("firmware", ""),
|
||||||
vendor=result.get("vendor", ""),
|
vendor=result.get("vendor", ""),
|
||||||
|
serial=result.get("serial", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
for neighbor in result["neighbors"]:
|
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}",
|
"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:
|
try:
|
||||||
merge_duplicate_switches()
|
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
|
scan_state["dept_filter"] = None
|
||||||
|
|
||||||
logger.info(f"Scan complete. OK: {scan_state['ok']}, Failed: {scan_state['fail']}")
|
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 concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
|
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,
|
from parser import (parse_lldp_neighbors, parse_mgmt_ip_from_interfaces,
|
||||||
parse_aruba_procurve_local, parse_aruba_procurve_neighbors,
|
parse_aruba_procurve_local, parse_aruba_procurve_neighbors,
|
||||||
normalize_mac)
|
normalize_mac)
|
||||||
@@ -35,12 +36,18 @@ def _device_type_for(manufacturer: str) -> str:
|
|||||||
return DEVICE_TYPE
|
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):
|
def connect_and_query(ip, login_delay=3, device_type=None):
|
||||||
|
username, password = _get_credentials()
|
||||||
device = {
|
device = {
|
||||||
"device_type": device_type or DEVICE_TYPE,
|
"device_type": device_type or DEVICE_TYPE,
|
||||||
"host": ip,
|
"host": ip,
|
||||||
"username": SSH_USERNAME,
|
"username": username,
|
||||||
"password": SSH_PASSWORD,
|
"password": password,
|
||||||
"port": SSH_PORT,
|
"port": SSH_PORT,
|
||||||
"timeout": SSH_TIMEOUT,
|
"timeout": SSH_TIMEOUT,
|
||||||
"global_delay_factor": 2,
|
"global_delay_factor": 2,
|
||||||
@@ -78,9 +85,9 @@ def connect_and_query(ip, login_delay=3, device_type=None):
|
|||||||
vendor = _detect_vendor(version_output)
|
vendor = _detect_vendor(version_output)
|
||||||
|
|
||||||
if vendor == 'aruba_procurve':
|
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:
|
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:
|
finally:
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
|
|
||||||
@@ -94,6 +101,7 @@ def connect_and_query(ip, login_delay=3, device_type=None):
|
|||||||
"mgmt_ip": mgmt_ip,
|
"mgmt_ip": mgmt_ip,
|
||||||
"description": model,
|
"description": model,
|
||||||
"firmware": firmware,
|
"firmware": firmware,
|
||||||
|
"serial": serial,
|
||||||
"vendor": vendor,
|
"vendor": vendor,
|
||||||
"neighbors": neighbors,
|
"neighbors": neighbors,
|
||||||
}
|
}
|
||||||
@@ -110,7 +118,7 @@ def connect_and_query(ip, login_delay=3, device_type=None):
|
|||||||
|
|
||||||
|
|
||||||
def _query_fs(conn, version_output, ip):
|
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)
|
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)
|
local_info_output = conn.send_command("show lldp local-information", read_timeout=30)
|
||||||
lldp_output = conn.send_command("show lldp neighbors", 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)
|
model = _fs_model(version_output) or _extract_system_desc(local_info_output)
|
||||||
firmware = _fs_firmware(version_output)
|
firmware = _fs_firmware(version_output)
|
||||||
|
serial = _extract_serial(version_output)
|
||||||
neighbors = parse_lldp_neighbors(lldp_output, chassis_id, '', mgmt_ip)
|
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):
|
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)
|
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)
|
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)
|
local_info = parse_aruba_procurve_local(local_output)
|
||||||
chassis_id = local_info['chassis_id'] or _extract_mac_from_version(version_output) or ip
|
chassis_id = local_info['chassis_id'] or _extract_mac_from_version(version_output) or ip
|
||||||
mgmt_ip = local_info['mgmt_ip'] or ip
|
mgmt_ip = local_info['mgmt_ip'] or ip
|
||||||
model = _aruba_model(local_info.get('system_desc', ''))
|
model = _aruba_model(local_info.get('system_desc', ''))
|
||||||
firmware = _aruba_firmware(version_output)
|
firmware = _aruba_firmware(version_output)
|
||||||
|
serial = _extract_serial(sysinfo_output) or _extract_serial(version_output)
|
||||||
neighbors = parse_aruba_procurve_neighbors(remote_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 ──────────────────────────────────────────────────────────
|
# ── Vendor detection ──────────────────────────────────────────────────────────
|
||||||
@@ -165,6 +176,16 @@ def _extract_mac_from_version(output):
|
|||||||
return m.group(1).strip() if m else None
|
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):
|
def _fs_model(output):
|
||||||
m = re.search(r'(?:Model Name|Product)\s*:\s*([\w\-]+)', output, re.IGNORECASE)
|
m = re.search(r'(?:Model Name|Product)\s*:\s*([\w\-]+)', output, re.IGNORECASE)
|
||||||
return f"FS {m.group(1)}" if m else ''
|
return f"FS {m.group(1)}" if m else ''
|
||||||
@@ -203,8 +224,10 @@ def _aruba_firmware(version_output):
|
|||||||
|
|
||||||
# ── Scan orchestration ────────────────────────────────────────────────────────
|
# ── Scan orchestration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_delay=3):
|
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'."""
|
"""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 = []
|
results = []
|
||||||
total = len(switch_list)
|
total = len(switch_list)
|
||||||
done = 0
|
done = 0
|
||||||
@@ -217,7 +240,8 @@ def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_
|
|||||||
ip, dt = entry, None
|
ip, dt = entry, None
|
||||||
return ip, executor.submit(connect_and_query, ip, login_delay=login_delay, device_type=dt)
|
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 = {}
|
future_to_ip = {}
|
||||||
for entry in switch_list:
|
for entry in switch_list:
|
||||||
ip, fut = _submit(entry)
|
ip, fut = _submit(entry)
|
||||||
@@ -237,4 +261,12 @@ def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(done, total, ip, result)
|
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
|
return results
|
||||||
|
|||||||
+45
-59
@@ -16,13 +16,8 @@ logger = logging.getLogger(__name__)
|
|||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
DB_PATH = "/opt/lldp-mapper/data/network.db"
|
DB_PATH = "/opt/lldp-mapper/data/network.db"
|
||||||
|
|
||||||
NETBOX_URL = "http://192.168.16.130:8001"
|
NETBOX_URL = "http://192.168.16.137:8000"
|
||||||
NETBOX_TOKEN = "nbt_T6aq9XpwNFQG.HtZziXuSATgabbeagWKk3vEhc2Ask1EMV210PWMM"
|
NETBOX_TOKEN = "nbt_LYkI6iSsflIU.9N4ziQxpbrV1AGPmpk0fwhfhJOSrSr0nGXEF4Ze2"
|
||||||
|
|
||||||
SITE_NAME = "Field Sites"
|
|
||||||
DEVICE_ROLE = "Access Switch"
|
|
||||||
MANUFACTURER = "FS"
|
|
||||||
DEVICE_TYPE = "FS Switch"
|
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
@@ -72,54 +67,21 @@ def load_db():
|
|||||||
|
|
||||||
|
|
||||||
# --- NetBox Sync ---
|
# --- 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():
|
def ensure_device(switch):
|
||||||
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):
|
|
||||||
hostname = switch["hostname"] or switch["mgmt_ip"]
|
hostname = switch["hostname"] or switch["mgmt_ip"]
|
||||||
results = nb_get("dcim/devices/", params={"name": hostname}).get("results", [])
|
results = nb_get("dcim/devices/", params={"name": hostname}).get("results", [])
|
||||||
if results:
|
if results:
|
||||||
logger.info(f" Device exists: {hostname}")
|
logger.info(f" Device exists: {hostname}")
|
||||||
return results[0]
|
return results[0]
|
||||||
logger.info(f" Creating device: {hostname}")
|
logger.warning(f" Skipping {hostname} — no matching device in NetBox (add it manually first)")
|
||||||
return nb_post("dcim/devices/", {
|
return None
|
||||||
"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",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_interface(device_id, port_name):
|
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):
|
def ensure_ip(switch, device_id):
|
||||||
if not switch.get("mgmt_ip"):
|
if not switch.get("mgmt_ip"):
|
||||||
return
|
return
|
||||||
@@ -187,29 +178,24 @@ def ensure_cable(device_map, link):
|
|||||||
"a_terminations": [{"object_type": "dcim.interface", "object_id": iface_a["id"]}],
|
"a_terminations": [{"object_type": "dcim.interface", "object_id": iface_a["id"]}],
|
||||||
"b_terminations": [{"object_type": "dcim.interface", "object_id": iface_b["id"]}],
|
"b_terminations": [{"object_type": "dcim.interface", "object_id": iface_b["id"]}],
|
||||||
"status": "connected",
|
"status": "connected",
|
||||||
|
"label": f"{hn_a}→{hn_b}",
|
||||||
|
"type": "smf-os2",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def sync_netbox(switches, links):
|
def sync_netbox(switches, links):
|
||||||
logger.info("=== Syncing to NetBox ===")
|
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 = {}
|
device_map = {}
|
||||||
|
|
||||||
for sw in switches:
|
for sw in switches:
|
||||||
hostname = sw["hostname"] or sw["mgmt_ip"]
|
hostname = sw["hostname"] or sw["mgmt_ip"]
|
||||||
logger.info(f"Processing device: {hostname}")
|
logger.info(f"Processing device: {hostname}")
|
||||||
device = ensure_device(sw, site_id, device_type_id, role_id)
|
device = ensure_device(sw)
|
||||||
if device:
|
if device:
|
||||||
device_map[hostname] = device
|
device_map[hostname] = device
|
||||||
|
ensure_serial(sw, device)
|
||||||
|
ensure_mac_comment(sw, device)
|
||||||
ensure_ip(sw, device["id"])
|
ensure_ip(sw, device["id"])
|
||||||
|
|
||||||
logger.info(f"Devices synced: {len(device_map)}")
|
logger.info(f"Devices synced: {len(device_map)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user