# db.py - SQLite database operations import sqlite3 import re import os import logging from config import DB_PATH logger = logging.getLogger(__name__) def get_conn(): conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10) conn.row_factory = sqlite3.Row return conn def init_db(): os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = get_conn() c = conn.cursor() c.execute(""" CREATE TABLE IF NOT EXISTS switches ( chassis_id TEXT PRIMARY KEY, hostname TEXT, mgmt_ip TEXT, description TEXT, last_seen TEXT ) """) c.execute(""" CREATE TABLE IF NOT EXISTS links ( id INTEGER PRIMARY KEY AUTOINCREMENT, chassis_a TEXT, port_a TEXT, chassis_b TEXT, port_b TEXT, UNIQUE(chassis_a, port_a, chassis_b, port_b) ) """) c.execute(""" CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT ) """) c.execute(""" CREATE TABLE IF NOT EXISTS scan_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT, finished_at TEXT, status TEXT, switches_ok INTEGER DEFAULT 0, switches_fail INTEGER DEFAULT 0 ) """) c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_enabled', 'false')") c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_interval', '60')") c.execute( "CREATE TABLE IF NOT EXISTS node_positions " "(chassis_id TEXT PRIMARY KEY, x REAL, y REAL)" ) conn.commit() conn.close() def upsert_switch(chassis_id, hostname, mgmt_ip, description): conn = get_conn() conn.execute(""" INSERT INTO switches (chassis_id, hostname, mgmt_ip, description, 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 NOT LIKE '%.%.%.%' = 0 THEN excluded.mgmt_ip ELSE mgmt_ip END, description = CASE WHEN excluded.description != '' THEN excluded.description ELSE description END, last_seen = excluded.last_seen """, (chassis_id, hostname, mgmt_ip, description)) conn.commit() conn.close() def upsert_link(chassis_a, port_a, chassis_b, port_b): # 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() # Check both orderings before inserting existing = conn.execute(""" SELECT id FROM links WHERE (chassis_a=? AND port_a=? AND chassis_b=? AND port_b=?) OR (chassis_a=? AND port_a=? AND chassis_b=? AND port_b=?) """, (chassis_a, port_a, chassis_b, port_b, chassis_b, port_b, chassis_a, port_a)).fetchone() if not existing: conn.execute(""" INSERT OR IGNORE INTO links (chassis_a, port_a, chassis_b, port_b) VALUES (?, ?, ?, ?) """, (chassis_a, port_a, chassis_b, port_b)) conn.commit() conn.close() def clear_stale_switches(since_dt): """Remove switches not seen since the given datetime string.""" conn = get_conn() conn.execute("DELETE FROM switches WHERE last_seen < ? OR last_seen IS NULL", (since_dt,)) deleted = conn.execute("SELECT changes()").fetchone()[0] conn.commit() conn.close() return deleted def clear_links(): conn = get_conn() conn.execute("DELETE FROM links") conn.commit() conn.close() def _is_mac(chassis): return bool(re.match(r'^[0-9A-Fa-f]{2}[-:][0-9A-Fa-f]{2}', chassis)) def _is_real_ip(val): return bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', val or '')) def merge_duplicate_switches(): """ After a scan, some switches may have two records: - One with MAC chassis_id (seen as neighbor by another switch) - One with IP chassis_id (directly scanned but chassis extraction failed) This function merges them: keeps MAC as canonical, fixes links, removes IP record. """ conn = get_conn() dupes = conn.execute(""" SELECT hostname, COUNT(*) as cnt, GROUP_CONCAT(chassis_id) as ids, GROUP_CONCAT(mgmt_ip) as ips FROM switches GROUP BY hostname HAVING cnt > 1 """).fetchall() merged = 0 for row in dupes: ids = row['ids'].split(',') ips_raw = row['ips'].split(',') mac_ids = [i for i in ids if _is_mac(i)] ip_ids = [i for i in ids if not _is_mac(i)] real_ips = [v for v in ips_raw if _is_real_ip(v)] if not mac_ids or not ip_ids: continue canonical = mac_ids[0] best_ip = real_ips[0] if real_ips else '' # Update canonical with best mgmt_ip if best_ip: conn.execute("UPDATE switches SET mgmt_ip=? WHERE chassis_id=?", (best_ip, canonical)) # Repoint links for old_id in ip_ids: conn.execute("UPDATE links SET chassis_a=? WHERE chassis_a=?", (canonical, old_id)) conn.execute("UPDATE links SET chassis_b=? WHERE chassis_b=?", (canonical, old_id)) conn.execute("DELETE FROM switches WHERE chassis_id=?", (old_id,)) merged += 1 logger.info(f"Merged {row['hostname']}: {ip_ids} -> {canonical} (mgmt_ip={best_ip})") # Remove any self-links or exact duplicate links created by repointing # Remove duplicate links - normalize chassis+port order before grouping conn.execute(""" DELETE FROM links WHERE id NOT IN ( SELECT MIN(id) FROM links GROUP BY CASE WHEN chassis_a < chassis_b THEN chassis_a ELSE chassis_b END, CASE WHEN chassis_a < chassis_b THEN chassis_b ELSE chassis_a END, CASE WHEN chassis_a < chassis_b THEN port_a ELSE port_b END, CASE WHEN chassis_a < chassis_b THEN port_b ELSE port_a END ) """) conn.execute("DELETE FROM links WHERE chassis_a = chassis_b") conn.commit() conn.close() logger.info(f"merge_duplicate_switches: merged {merged} hostname groups") def get_all_switches(): conn = get_conn() rows = conn.execute("SELECT * FROM switches ORDER BY hostname").fetchall() conn.close() return [dict(r) for r in rows] def get_all_links(): conn = get_conn() rows = conn.execute(""" SELECT l.*, sa.hostname as hostname_a, sa.mgmt_ip as ip_a, sb.hostname as hostname_b, sb.mgmt_ip as ip_b FROM links l LEFT JOIN switches sa ON l.chassis_a = sa.chassis_id LEFT JOIN switches sb ON l.chassis_b = sb.chassis_id """).fetchall() conn.close() return [dict(r) for r in rows] def get_setting(key): conn = get_conn() row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() conn.close() return row["value"] if row else None def set_setting(key, value): conn = get_conn() conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, str(value))) conn.commit() conn.close() def log_scan_start(): conn = get_conn() c = conn.cursor() c.execute("INSERT INTO scan_log (started_at, status) VALUES (datetime('now'), 'running')") scan_id = c.lastrowid conn.commit() conn.close() return scan_id def log_scan_finish(scan_id, ok_count, fail_count): conn = get_conn() conn.execute(""" UPDATE scan_log SET finished_at=datetime('now'), status='done', switches_ok=?, switches_fail=? WHERE id=? """, (ok_count, fail_count, scan_id)) conn.commit() conn.close() def get_last_scan(): conn = get_conn() row = conn.execute( "SELECT * FROM scan_log ORDER BY id DESC LIMIT 1" ).fetchone() conn.close() return dict(row) if row else None def deduplicate_links(): """Remove duplicate bidirectional links. Always run after a scan.""" conn = get_conn() before = conn.execute("SELECT COUNT(*) FROM links").fetchone()[0] conn.execute(""" DELETE FROM links WHERE id NOT IN ( SELECT MIN(id) FROM links GROUP BY CASE WHEN chassis_a < chassis_b THEN chassis_a ELSE chassis_b END, CASE WHEN chassis_a < chassis_b THEN chassis_b ELSE chassis_a END, CASE WHEN chassis_a < chassis_b THEN port_a ELSE port_b END, CASE WHEN chassis_a < chassis_b THEN port_b ELSE port_a END ) """) conn.execute("DELETE FROM links WHERE chassis_a = chassis_b") conn.commit() after = conn.execute("SELECT COUNT(*) FROM links").fetchone()[0] conn.close() logger.info(f"deduplicate_links: {before} -> {after} links") def save_node_positions(positions): """Save node positions from Cytoscape. positions = list of {chassis_id, x, y}""" conn = get_conn() conn.execute("DELETE FROM node_positions") for p in positions: conn.execute( "INSERT INTO node_positions (chassis_id, x, y) VALUES (?, ?, ?)", (p['chassis_id'], float(p['x']), float(p['y'])) ) conn.commit() conn.close() logging.getLogger(__name__).info(f"Saved {len(positions)} node positions") def get_node_positions(): """Returns dict of chassis_id -> (x, y), empty if none saved.""" conn = get_conn() rows = conn.execute("SELECT chassis_id, x, y FROM node_positions").fetchall() conn.close() return {r["chassis_id"]: (r["x"], r["y"]) for r in rows} def clear_node_positions(): conn = get_conn() conn.execute("DELETE FROM node_positions") conn.commit() conn.close() logging.getLogger(__name__).info("Node positions cleared")