From d6bf3942971cd238ad58fcc9637c8f7b12fccfea Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 4 Mar 2026 10:02:52 -0700 Subject: [PATCH] Add optional policy MIB toggle and pre-walk ping check - Add SNMP_WALK_POLICIES env var and UI checkbox to skip ACD-POLICY-MIB (~73% of all OIDs), cutting walk time from ~25s to ~11s - Add /api/ping endpoint with reachability check before walk starts - Show "NID Management is UP" (green) or "NID is DOWN" (red) status - Block walk if target is unreachable Co-Authored-By: Claude Opus 4.6 --- .env.example | 5 + build_nid_viewer.py | 521 +++++++++++++++++++++++++------------------- nid-server.py | 47 +++- snmp-walk.sh | 7 +- 4 files changed, 352 insertions(+), 228 deletions(-) diff --git a/.env.example b/.env.example index ffd030f..357d8d6 100644 --- a/.env.example +++ b/.env.example @@ -22,5 +22,10 @@ SNMP_COMMUNITY=public # "targeted" = walk only subtrees used by the viewer (faster) SNMP_WALK_MODE=targeted +# ── Policy data ── +# ACD-POLICY-MIB is ~73% of all OIDs. Set to "false" to skip it for faster walks. +# The Traffic Policies card will be empty when disabled. +SNMP_WALK_POLICIES=true + # ── Server ── SERVER_PORT=5525 diff --git a/build_nid_viewer.py b/build_nid_viewer.py index 1e76e8d..4da0e86 100644 --- a/build_nid_viewer.py +++ b/build_nid_viewer.py @@ -60,6 +60,8 @@ def build_html(data: dict) -> str: NID Viewer — {page_title} + + -
+
- -
+ +
+
+
+
@@ -630,20 +684,20 @@ body {{
- -
- - -
+ +
+
+
+
- -
- - -
+ +
+
+
+
@@ -775,6 +829,10 @@ function renderWalkControl() {{ + @@ -795,6 +853,7 @@ let walkEventSource = null; function startWalk() {{ const target = document.getElementById('walk-target').value.trim(); const mode = document.getElementById('walk-mode').value; + const policies = document.getElementById('walk-policies').checked; const btn = document.getElementById('walk-btn'); if (!target) {{ @@ -803,55 +862,78 @@ function startWalk() {{ }} btn.disabled = true; - btn.innerHTML = ' Walking...'; - updateWalkStatus('running', 'Starting walk...'); + btn.innerHTML = ' Pinging...'; + updateWalkStatus('running', 'Checking reachability...'); - // Close any previous SSE connection - if (walkEventSource) walkEventSource.close(); - - fetch('/api/walk', {{ + // Step 1: Ping check + fetch('/api/ping', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, - body: JSON.stringify({{ target, mode }}) + body: JSON.stringify({{ target }}) }}) .then(r => r.json()) - .then(resp => {{ - if (resp.error) {{ - updateWalkStatus('error', resp.error); + .then(ping => {{ + if (!ping.reachable) {{ + updateWalkStatus('error', 'NID is DOWN. Verify Local Power and Router Interface Status.'); resetWalkBtn(); return; }} - // Open SSE for status updates - walkEventSource = new EventSource('/api/status'); - walkEventSource.onmessage = (e) => {{ - const s = JSON.parse(e.data); - const pct = s.progress || 0; - document.getElementById('walk-progress-fill').style.width = pct + '%'; + updateWalkStatus('complete', 'NID Management is UP'); - if (s.state === 'complete') {{ - updateWalkStatus('complete', s.message); - walkEventSource.close(); - walkEventSource = null; - // Reload to pick up fresh data - setTimeout(() => window.location.reload(), 800); - }} else if (s.state === 'error') {{ - updateWalkStatus('error', s.message); - walkEventSource.close(); - walkEventSource = null; + // Step 2: Proceed with walk after brief pause to show UP status + setTimeout(() => {{ + btn.innerHTML = ' Walking...'; + updateWalkStatus('running', 'Starting walk...'); + + // Close any previous SSE connection + if (walkEventSource) walkEventSource.close(); + + fetch('/api/walk', {{ + method: 'POST', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ target, mode, policies }}) + }}) + .then(r => r.json()) + .then(resp => {{ + if (resp.error) {{ + updateWalkStatus('error', resp.error); + resetWalkBtn(); + return; + }} + // Open SSE for status updates + walkEventSource = new EventSource('/api/status'); + walkEventSource.onmessage = (e) => {{ + const s = JSON.parse(e.data); + const pct = s.progress || 0; + document.getElementById('walk-progress-fill').style.width = pct + '%'; + + if (s.state === 'complete') {{ + updateWalkStatus('complete', s.message); + walkEventSource.close(); + walkEventSource = null; + setTimeout(() => window.location.reload(), 800); + }} else if (s.state === 'error') {{ + updateWalkStatus('error', s.message); + walkEventSource.close(); + walkEventSource = null; + resetWalkBtn(); + }} else {{ + updateWalkStatus('running', s.message); + }} + }}; + walkEventSource.onerror = () => {{ + walkEventSource.close(); + walkEventSource = null; + }}; + }}) + .catch(err => {{ + updateWalkStatus('error', 'Failed to connect: ' + err.message); resetWalkBtn(); - }} else {{ - updateWalkStatus('running', s.message); - }} - }}; - walkEventSource.onerror = () => {{ - walkEventSource.close(); - walkEventSource = null; - // SSE closed — could be server-side close after complete/error - // Check if we already handled it; if not, it was an unexpected close - }}; + }}); + }}, 600); }}) .catch(err => {{ - updateWalkStatus('error', 'Failed to connect: ' + err.message); + updateWalkStatus('error', 'Ping check failed: ' + err.message); resetWalkBtn(); }}); }} @@ -983,6 +1065,40 @@ function renderHeader() {{
`; }} +// ── 1b. Location Map ──────────────────────────────── +function renderMap() {{ + const d = DATA.device || {{}}; + const loc = (d.sysLocation || '').trim(); + const m = loc.match(/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/); + if (!m) return; // no valid coordinates — skip map + + const lat = parseFloat(m[1]); + const lon = parseFloat(m[2]); + const hostname = d.sysName || d.identifier || 'NID'; + + document.getElementById('sec-map').innerHTML = ` +
+
Location Map
+
+
+
${{lat.toFixed(6)}}, ${{lon.toFixed(6)}}
+
+
`; + + const map = L.map('nid-map').setView([lat, lon], 15); + L.tileLayer('https://{{s}}.basemaps.cartocdn.com/dark_all/{{z}}/{{x}}/{{y}}{{r}}.png', {{ + attribution: '© OSM © CARTO', + subdomains: 'abcd', + maxZoom: 19 + }}).addTo(map); + L.marker([lat, lon]).addTo(map) + .bindPopup(`${{esc(hostname)}}
${{lat.toFixed(6)}}, ${{lon.toFixed(6)}}`) + .openPopup(); + + // Leaflet needs a resize nudge when rendered in a hidden/collapsed container + setTimeout(() => map.invalidateSize(), 200); +}} + // ── 2. Front Panel ─────────────────────────────────── function renderPanel() {{ const connectors = DATA.connectors || {{}}; @@ -1652,7 +1768,6 @@ function parseRemotePlatform(sysDesc, sysName) {{ function renderLldp() {{ const neighbors = DATA.lldp_neighbors || {{}}; - const stats = DATA.lldp_stats || {{}}; const ifaces = DATA.interfaces || {{}}; const sfpInfo = DATA.sfp_info || {{}}; const device = DATA.device || {{}}; @@ -1664,7 +1779,6 @@ function renderLldp() {{ const localName = device.identifier || device.sysName || 'NID'; const localModel = device.commercialName || device.sysDescr || 'NID'; - // Determine slot state — connector-type aware (same logic as renderPanel) function slotState(portIdx) {{ const conn = connectors[portIdx]; const isSfp = conn && conn.type === '14'; @@ -1682,8 +1796,30 @@ function renderLldp() {{ }} }} - // Build a row for each network port (1-4) - let rowsHtml = ''; + function buildNeighborCard(nbr, cssClass) {{ + const platform = parseRemotePlatform(nbr.remSysDesc, nbr.remSysName); + const shortName = (nbr.remSysName || '').split('.')[0] || 'Unknown'; + const modelLine = platform.vendor ? `${{platform.vendor}} ${{platform.model}}` : platform.model; + return `
+
${{esc(shortName)}}
+
${{esc(modelLine)}}
+ ${{platform.firmware ? `
FW ${{esc(platform.firmware)}}
` : ''}} +
Port ${{esc(nbr.remPortId || '?')}}
+ ${{nbr.remPortDesc ? `
Desc ${{esc(nbr.remPortDesc)}}
` : ''}} +
MAC ${{esc(nbr.chassisId || '?')}}
+ ${{nbr.mgmtIPv4 ? `
${{esc(nbr.mgmtIPv4)}}
` : ''}} + ${{nbr.mgmtIPv6 ? `
IPv6 ${{esc(nbr.mgmtIPv6)}}
` : ''}} +
+ Caps + ${{nbr.capsEnabled === '2' ? 'Bridge' : + nbr.capsEnabled === '4' ? 'Router' : + 'Cap=' + (nbr.capsEnabled||'?')}} +
+
`; + }} + + // Build vertical columns for ports 1-4 + let colsHtml = ''; for (let i = 1; i <= 4; i++) {{ const portKey = String(i); const conn = connectors[portKey] || {{}}; @@ -1698,121 +1834,61 @@ function renderLldp() {{ state === 'present-link' ? '' : '') : ''; + const portLabel = iface.ifName || 'Port ' + i; if (nbr) {{ - const platform = parseRemotePlatform(nbr.remSysDesc, nbr.remSysName); - const shortName = (nbr.remSysName || '').split('.')[0] || 'Unknown'; - const modelLine = platform.vendor ? `${{platform.vendor}} ${{platform.model}}` : platform.model; const linkClass = localUp ? 'up' : 'down'; - - rowsHtml += ` -
-
+ colsHtml += ` +
+
${{icon}}${{esc(slotName)}}
-
- - - -
-
-
${{esc(shortName)}}
-
${{esc(modelLine)}}
- ${{platform.firmware ? `
FW ${{esc(platform.firmware)}}
` : ''}} -
Port ${{esc(nbr.remPortId || '?')}}
- ${{nbr.remPortDesc ? `
Desc ${{esc(nbr.remPortDesc)}}
` : ''}} -
MAC ${{esc(nbr.chassisId || '?')}}
- ${{nbr.mgmtIPv4 ? `
${{esc(nbr.mgmtIPv4)}}
` : ''}} - ${{nbr.mgmtIPv6 ? `
IPv6 ${{esc(nbr.mgmtIPv6)}}
` : ''}} -
- Caps - ${{nbr.capsEnabled === '2' ? 'Bridge' : - nbr.capsEnabled === '4' ? 'Router' : - 'Cap=' + (nbr.capsEnabled||'?')}} -
-
+
${{esc(portLabel)}}
+
+
${{esc(nbr.remPortId || '?')}}
+ ${{buildNeighborCard(nbr, 'lldp-col-remote')}}
`; }} else {{ - rowsHtml += ` -
-
+ colsHtml += ` +
+
${{icon}}${{esc(slotName)}}
-
- -
-
No LLDP neighbor
+
${{esc(portLabel)}}
+
+
No LLDP neighbor
`; }} }} - // Include MGMT port if it has a neighbor + // MGMT column (port 5) const mgmtNbr = neighborByPort['5']; if (mgmtNbr) {{ const mgmtIface = ifaces['5'] || {{}}; const mgmtUp = isUp(mgmtIface.ifOperStatus); - const platform = parseRemotePlatform(mgmtNbr.remSysDesc, mgmtNbr.remSysName); - const shortName = (mgmtNbr.remSysName || '').split('.')[0] || 'Unknown'; - const modelLine = platform.vendor ? `${{platform.vendor}} ${{platform.model}}` : platform.model; - rowsHtml += ` -
-
+ colsHtml += `
`; + colsHtml += ` +
+
MGMT
-
- - - -
-
-
${{esc(shortName)}}
-
${{esc(modelLine)}}
-
MAC ${{esc(mgmtNbr.chassisId || '?')}}
- ${{mgmtNbr.mgmtIPv4 ? `
${{esc(mgmtNbr.mgmtIPv4)}}
` : ''}} -
+
Management
+
+
${{esc(mgmtNbr.remPortId || '?')}}
+ ${{buildNeighborCard(mgmtNbr, 'lldp-col-remote')}}
`; }} - // Build per-port LLDP stats table - let statsRows = ''; - for (const [port, s] of Object.entries(stats).sort((a,b) => parseInt(a[0]) - parseInt(b[0]))) {{ - const tx = parseInt(s.txFrames || '0').toLocaleString(); - const rx = parseInt(s.rxFrames || '0').toLocaleString(); - const nb = s.neighborsLearned || '0'; - const name = (ifaces[port] || {{}}).ifName || `Port ${{port}}`; - const hasActive = !!neighborByPort[port]; - const activeMarker = hasActive ? ' (active)' : ''; - statsRows += ` - ${{parseInt(port) <= 4 ? 'SFP-' + port : 'MGMT'}} - ${{esc(name)}} - ${{tx}} - ${{rx}} - ${{nb}}${{activeMarker}} - `; - }} - document.getElementById('sec-lldp').innerHTML = `
LLDP Topology
${{esc(localName)}} — ${{esc(localModel)}} - ${{rowsHtml}} -
- ${{Object.keys(stats).length ? ` -
-
- Per-Port LLDP Statistics -
- - - - - - ${{statsRows}} -
PortInterfaceTX FramesRX FramesNeighbors
+
+ ${{colsHtml}}
- ` : ''}} +
`; }} @@ -2031,6 +2107,7 @@ function renderPortCmp() {{ // ── Render all sections ────────────────────────────── renderWalkControl(); renderHeader(); +renderMap(); renderPanel(); renderLldp(); renderInterfaces(); diff --git a/nid-server.py b/nid-server.py index 254731c..eab23d4 100644 --- a/nid-server.py +++ b/nid-server.py @@ -41,7 +41,6 @@ TARGETED_OIDS = [ (".1.3.6.1.4.1.22420.1.1", "ACD-DESC-MIB"), (".1.3.6.1.4.1.22420.2.1", "ACD-ALARM-MIB"), (".1.3.6.1.4.1.22420.2.2", "ACD-FILTER-MIB"), - (".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB"), (".1.3.6.1.4.1.22420.2.4", "ACD-SFP-MIB"), (".1.3.6.1.4.1.22420.2.6", "ACD-REGULATOR-MIB"), (".1.3.6.1.4.1.22420.2.8", "ACD-SMAP-MIB"), @@ -79,6 +78,10 @@ SNMP_V3_PRIV_PROTO = ENV.get("SNMP_V3_PRIV_PROTO", "AES") SNMP_V3_PRIV_PASS = ENV.get("SNMP_V3_PRIV_PASS", "") SNMP_V3_SEC_LEVEL = ENV.get("SNMP_V3_SEC_LEVEL", "authPriv") +# Conditionally include heavy policy MIB (~73% of all OIDs) +if ENV.get("SNMP_WALK_POLICIES", "true").lower() == "true": + TARGETED_OIDS.append((".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB")) + # ── Walk state (shared across threads) ─────────────────────────────── walk_lock = threading.Lock() @@ -125,7 +128,7 @@ def build_snmp_auth() -> list: return ["-v", SNMP_VERSION, "-c", SNMP_COMMUNITY] -def run_walk(target: str, mode: str): +def run_walk(target: str, mode: str, policies: bool = True): """Execute the full walk pipeline in a background thread.""" global latest_json @@ -142,6 +145,11 @@ def run_walk(target: str, mode: str): auth = build_snmp_auth() t_start = time.time() + # Build OID list for this walk — optionally exclude heavy policy MIB + walk_oids = list(TARGETED_OIDS) + if not policies: + walk_oids = [(oid, lbl) for oid, lbl in walk_oids if lbl != "ACD-POLICY-MIB"] + try: # ── Step 1: snmpwalk ────────────────────────────────────── # Use snmpbulkwalk (GETBULK PDUs) when available — much faster @@ -157,7 +165,7 @@ def run_walk(target: str, mode: str): walk_file.write_text(result.stdout) else: # Walk subtrees in parallel for speed - total = len(TARGETED_OIDS) + total = len(walk_oids) completed = [0] # mutable counter for progress results_map = {} @@ -175,7 +183,7 @@ def run_walk(target: str, mode: str): with ThreadPoolExecutor(max_workers=4) as pool: futures = [ pool.submit(walk_subtree, i, oid, label) - for i, (oid, label) in enumerate(TARGETED_OIDS) + for i, (oid, label) in enumerate(walk_oids) ] for fut in as_completed(futures): idx, output = fut.result() @@ -309,11 +317,39 @@ class NIDHandler(BaseHTTPRequestHandler): def do_POST(self): if self.path == "/api/walk": self._handle_walk() + elif self.path == "/api/ping": + self._handle_ping() elif self.path == "/api/clear": self._handle_clear() else: self._send(404, "text/plain", b"Not found") + def _handle_ping(self): + """Ping a target IP to check reachability.""" + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length else {} + target = body.get("target", "").strip() + + if not target: + self._send_json(400, {"error": "target IP required"}) + return + + ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$") + if not ip_re.match(target): + self._send_json(400, {"error": f"Invalid IP address: {target}"}) + return + + try: + result = subprocess.run( + ["ping", "-c", "1", "-W", "2", target], + capture_output=True, text=True, timeout=5, + ) + reachable = result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + reachable = False + + self._send_json(200, {"reachable": reachable, "target": target}) + def _handle_walk(self): """Start a walk in a background thread.""" current, _ = get_status() @@ -325,6 +361,7 @@ class NIDHandler(BaseHTTPRequestHandler): body = json.loads(self.rfile.read(length)) if length else {} target = body.get("target", "").strip() mode = body.get("mode", "targeted").strip() + policies = body.get("policies", True) if not target: self._send_json(400, {"error": "target IP required"}) @@ -334,7 +371,7 @@ class NIDHandler(BaseHTTPRequestHandler): return set_status("walking", message="Starting walk...", progress=1) - thread = threading.Thread(target=run_walk, args=(target, mode), daemon=True) + thread = threading.Thread(target=run_walk, args=(target, mode, policies), daemon=True) thread.start() self._send_json(200, {"status": "started", "target": target, "mode": mode}) diff --git a/snmp-walk.sh b/snmp-walk.sh index 21f8f96..3228312 100755 --- a/snmp-walk.sh +++ b/snmp-walk.sh @@ -75,6 +75,8 @@ fi # ── Define OID subtrees ────────────────────────────────────────────── # Targeted: only what the viewer/parser needs +WALK_POLICIES="${SNMP_WALK_POLICIES:-true}" + TARGETED_OIDS=( .1.3.6.1.2.1.1 # System (sysDescr, sysName, sysUpTime, …) .1.3.6.1.2.1.2 # IF-MIB (interface table) @@ -85,13 +87,16 @@ TARGETED_OIDS=( .1.3.6.1.4.1.22420.1.1 # ACD-DESC-MIB (device identity, connectors, sensors) .1.3.6.1.4.1.22420.2.1 # ACD-ALARM-MIB .1.3.6.1.4.1.22420.2.2 # ACD-FILTER-MIB - .1.3.6.1.4.1.22420.2.3 # ACD-POLICY-MIB (L2 policies, stats) .1.3.6.1.4.1.22420.2.4 # ACD-SFP-MIB (transceiver info/diag) .1.3.6.1.4.1.22420.2.6 # ACD-REGULATOR-MIB .1.3.6.1.4.1.22420.2.8 # ACD-SMAP-MIB (CoS profiles) .1.3.6.1.4.1.22420.2.9 # ACD-PORT-MIB (port config/status) ) +if [[ "$WALK_POLICIES" == "true" ]]; then + TARGETED_OIDS+=(.1.3.6.1.4.1.22420.2.3) # ACD-POLICY-MIB (~73% of all OIDs) +fi + # ── Prepare output paths ───────────────────────────────────────────── TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)" SAFE_IP="${TARGET//./-}"