diff --git a/.env.example b/.env.example
index 357d8d6..b5cc28e 100644
--- a/.env.example
+++ b/.env.example
@@ -27,5 +27,11 @@ SNMP_WALK_MODE=targeted
# The Traffic Policies card will be empty when disabled.
SNMP_WALK_POLICIES=true
+# ── Neighbor Device SNMP ──
+# Credentials for polling LLDP-discovered neighbor devices (Cisco routers/switches)
+# Falls back to NID credentials (SNMP_COMMUNITY / v3 settings) if not set
+NEIGHBOR_SNMP_VERSION=2c
+NEIGHBOR_SNMP_COMMUNITY=public
+
# ── Server ──
SERVER_PORT=5525
diff --git a/build_nid_viewer.py b/build_nid_viewer.py
index 7393862..c83d656 100644
--- a/build_nid_viewer.py
+++ b/build_nid_viewer.py
@@ -407,6 +407,11 @@ body {{
0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.4; }}
}}
+@keyframes spin {{
+ from {{ transform: rotate(0deg); }}
+ to {{ transform: rotate(360deg); }}
+}}
+.spin {{ animation: spin 1s linear infinite; display: inline-block; }}
.walk-progress {{
height: 3px;
background: var(--border-color);
@@ -620,6 +625,46 @@ body {{
border-left: 1px dashed #3a3f4b;
margin: 0 0.25rem;
}}
+/* ── Poll Neighbor Button ── */
+.btn-poll-neighbor {{
+ display: block; width: 100%; margin-top: 0.4rem;
+ padding: 0.25rem 0.4rem; font-size: 0.7rem;
+ background: var(--card-bg); color: var(--accent);
+ border: 1px solid var(--accent); border-radius: 4px;
+ cursor: pointer; text-align: center; transition: all 0.15s;
+}}
+.btn-poll-neighbor:hover {{ background: var(--accent); color: #fff; }}
+.btn-poll-neighbor:disabled {{ opacity: 0.5; cursor: not-allowed; }}
+.btn-poll-neighbor i {{ margin-right: 0.2rem; }}
+/* ── Neighbor Device Section ── */
+.neighbor-card {{ margin-bottom: 1rem; }}
+.neighbor-header {{
+ display: flex; align-items: center; gap: 0.5rem;
+ padding: 0.5rem 0.75rem; font-size: 0.85rem; font-weight: 600;
+}}
+.neighbor-header .nbr-platform {{ font-weight: 400; color: var(--text-muted); font-size: 0.75rem; }}
+.neighbor-intf {{
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 0.75rem; padding: 0.75rem;
+}}
+.nbr-intf-card {{
+ background: var(--card-bg); border: 1px solid #2a2e38; border-radius: 6px;
+ padding: 0.6rem; font-size: 0.75rem;
+}}
+.nbr-intf-card .intf-name {{ font-weight: 600; font-size: 0.8rem; margin-bottom: 0.3rem; font-family: 'JetBrains Mono', monospace; }}
+.nbr-intf-card .intf-alias {{ color: var(--text-muted); font-style: italic; margin-bottom: 0.3rem; }}
+.nbr-status-badge {{
+ display: inline-block; padding: 0.1rem 0.4rem; border-radius: 3px;
+ font-size: 0.65rem; font-weight: 600; text-transform: uppercase;
+}}
+.nbr-status-badge.up {{ background: rgba(0,200,83,0.15); color: var(--green); }}
+.nbr-status-badge.down {{ background: rgba(255,82,82,0.15); color: var(--red); }}
+.nbr-status-badge.admin-down {{ background: rgba(255,160,0,0.15); color: var(--amber); }}
+.nbr-detail {{ display: flex; justify-content: space-between; padding: 0.15rem 0; }}
+.nbr-detail .nbr-lbl {{ color: var(--text-muted); }}
+.nbr-sub-table {{ width: 100%; font-size: 0.72rem; margin-top: 0.5rem; }}
+.nbr-sub-table th {{ color: var(--text-muted); font-weight: 500; text-align: left; padding: 0.25rem 0.5rem; border-bottom: 1px solid #2a2e38; }}
+.nbr-sub-table td {{ padding: 0.25rem 0.5rem; border-bottom: 1px solid #1a1e28; }}
/* ── Location Map ── */
#sec-map .card-dark {{
height: 100%;
@@ -681,6 +726,9 @@ body {{
+
+
+
@@ -1835,6 +1883,10 @@ function renderLldp() {{
nbr.capsEnabled === '4' ? 'Router' :
'Cap=' + (nbr.capsEnabled||'?')}}
+ ${{nbr.mgmtIPv4 && !(nbr.remSysDesc || '').match(/AMN-|AMO-/) ?
+ `` : ''}}
`;
}}
@@ -1913,6 +1965,216 @@ function renderLldp() {{
`;
}}
+// ── 3b. Connected Neighbor ─────────────────────────────
+
+function pollNeighbor(target, remPortId, remSysName) {{
+ const btn = event.currentTarget;
+ btn.disabled = true;
+ btn.innerHTML = ' Polling...';
+
+ // Ping first
+ fetch('/api/ping', {{
+ method: 'POST',
+ headers: {{ 'Content-Type': 'application/json' }},
+ body: JSON.stringify({{ target }})
+ }})
+ .then(r => r.json())
+ .then(ping => {{
+ if (!ping.reachable) {{
+ btn.innerHTML = ' Unreachable';
+ btn.style.borderColor = 'var(--red)';
+ btn.style.color = 'var(--red)';
+ setTimeout(() => {{
+ btn.disabled = false;
+ btn.innerHTML = ' Poll Neighbor';
+ btn.style.borderColor = '';
+ btn.style.color = '';
+ }}, 3000);
+ return;
+ }}
+
+ // Start the neighbor walk
+ fetch('/api/neighbor-walk', {{
+ method: 'POST',
+ headers: {{ 'Content-Type': 'application/json' }},
+ body: JSON.stringify({{ target, remPortId, remSysName }})
+ }})
+ .then(r => r.json())
+ .then(() => {{
+ btn.innerHTML = ' Walking...';
+ // Poll for completion
+ const pollInterval = setInterval(() => {{
+ fetch(`/api/neighbor-status?target=${{encodeURIComponent(target)}}`)
+ .then(r => r.json())
+ .then(status => {{
+ if (status.state === 'complete') {{
+ clearInterval(pollInterval);
+ // Fetch the neighbor data and render it
+ fetch(`/api/neighbor-data?target=${{encodeURIComponent(target)}}`)
+ .then(r => r.json())
+ .then(ndata => {{
+ if (!DATA.neighbor_data) DATA.neighbor_data = {{}};
+ DATA.neighbor_data[target] = ndata;
+ renderNeighbor();
+ btn.disabled = false;
+ btn.innerHTML = ' Done';
+ btn.style.borderColor = 'var(--green)';
+ btn.style.color = 'var(--green)';
+ }});
+ }} else if (status.state === 'error') {{
+ clearInterval(pollInterval);
+ btn.disabled = false;
+ btn.innerHTML = ' Error';
+ btn.style.borderColor = 'var(--red)';
+ btn.style.color = 'var(--red)';
+ btn.title = status.message || 'Walk failed';
+ }} else {{
+ btn.innerHTML = ` ${{status.message || 'Walking...'}}`;
+ }}
+ }});
+ }}, 2000);
+ }});
+ }})
+ .catch(err => {{
+ btn.disabled = false;
+ btn.innerHTML = ' Error';
+ console.error('Neighbor poll error:', err);
+ }});
+}}
+
+function renderNeighbor() {{
+ const nd = DATA.neighbor_data || {{}};
+ const container = document.getElementById('sec-neighbor');
+ if (!Object.keys(nd).length) {{ container.innerHTML = ''; return; }}
+
+ let cardsHtml = '';
+ for (const [ip, ndata] of Object.entries(nd)) {{
+ const sys = ndata.neighbor_system || {{}};
+ const qi = ndata.queried_interface || {{}};
+ const subs = ndata.subinterfaces || {{}};
+ const vlans = ndata.vlans || {{}};
+ const optics = ndata.optics || {{}};
+
+ const shortName = (sys.sysName || ip).split('.')[0];
+ const adminUp = qi.ifAdminStatus === '1';
+ const operUp = qi.ifOperStatus === '1';
+ const statusClass = !adminUp ? 'admin-down' : operUp ? 'up' : 'down';
+ const statusText = !adminUp ? 'Admin Down' : operUp ? 'Up' : 'Down';
+
+ function formatSpeed(hs) {{
+ const n = parseInt(hs);
+ if (!n) return '?';
+ if (n >= 1000) return (n/1000) + ' Gbps';
+ return n + ' Mbps';
+ }}
+
+ function formatOctets(val) {{
+ const n = parseInt(val);
+ if (isNaN(n)) return '?';
+ if (n > 1e12) return (n/1e12).toFixed(2) + ' TB';
+ if (n > 1e9) return (n/1e9).toFixed(2) + ' GB';
+ if (n > 1e6) return (n/1e6).toFixed(2) + ' MB';
+ if (n > 1e3) return (n/1e3).toFixed(1) + ' KB';
+ return n + ' B';
+ }}
+
+ // Primary interface card
+ let intfHtml = `
+
+
${{esc(qi.ifDescr || qi.remPortId || '?')}}
+ ${{qi.ifAlias ? `
${{esc(qi.ifAlias)}}
` : ''}}
+
Status ${{statusText}}
+
Speed ${{formatSpeed(qi.ifHighSpeed || qi.ifSpeed)}}
+ ${{qi.ifMtu ? `
MTU ${{qi.ifMtu}}
` : ''}}
+
In ${{formatOctets(qi.ifHCInOctets || qi.ifInOctets)}}
+
Out ${{formatOctets(qi.ifHCOutOctets || qi.ifOutOctets)}}
+ ${{(qi.ifInErrors && qi.ifInErrors !== '0') || (qi.ifOutErrors && qi.ifOutErrors !== '0') ?
+ `
Errors In: ${{qi.ifInErrors}} / Out: ${{qi.ifOutErrors}}
` : ''}}
+
`;
+
+ // Optics card (if available)
+ if (optics.txPower || optics.rxPower) {{
+ intfHtml += `
+
+
Optics
+ ${{optics.txPower ? `
Tx Power ${{optics.txPower}} dBm
` : ''}}
+ ${{optics.rxPower ? `
Rx Power ${{optics.rxPower}} dBm
` : ''}}
+ ${{optics.temperature ? `
Temp ${{optics.temperature}} C
` : ''}}
+
`;
+ }}
+
+ // Subinterfaces table (IOS-XR style)
+ let subsHtml = '';
+ const subKeys = Object.keys(subs);
+ if (subKeys.length) {{
+ let subRows = '';
+ for (const sk of subKeys) {{
+ const s = subs[sk];
+ const sUp = s.ifOperStatus === '1';
+ subRows += `
+ | ${{esc(s.ifDescr || s.ifName || '?')}} |
+ ${{s.vlanId || '?'}} |
+ ${{sUp ? 'Up' : 'Down'}} |
+ ${{esc(s.ifAlias || '')}} |
+ ${{s.bvi_ifDescr ? esc(s.bvi_ifDescr) : '—'}} |
+ ${{s.bvi_ifDescr ? `${{s.bvi_ifOperStatus === '1' ? 'Up' : 'Down'}}` : ''}} |
+
`;
+ }}
+ subsHtml = `
+
+ | Subinterface | VLAN | Status | Description | BVI/BDI | L3 Status |
+ ${{subRows}}
+
`;
+ }}
+
+ // VLANs / SVIs table (IOS-XE style)
+ let vlansHtml = '';
+ const vlanKeys = Object.keys(vlans);
+ if (vlanKeys.length) {{
+ let vlanRows = '';
+ for (const vk of vlanKeys.sort((a,b) => parseInt(a) - parseInt(b))) {{
+ const v = vlans[vk];
+ const vUp = v.ifOperStatus === '1';
+ vlanRows += `
+ | Vlan${{vk}} |
+ ${{vUp ? 'Up' : 'Down'}} |
+ ${{esc(v.ifAlias || '')}} |
+ ${{esc(v.ifDescr || '')}} |
+
`;
+ }}
+ vlansHtml = `
+ Switch Virtual Interfaces (SVIs)
+
+ | SVI | Status | Description | ifDescr |
+ ${{vlanRows}}
+
`;
+ }}
+
+ cardsHtml += `
+
+
+
+ ${{intfHtml}}
+
+ ${{subsHtml}}
+ ${{vlansHtml}}
+
`;
+ }}
+
+ container.innerHTML = `
+ `;
+ initCollapsible();
+}}
+
// ── 7. Coverage Matrix ───────────────────────────────
function renderCoverage() {{
// Analyze each section for populated vs empty fields
@@ -2130,6 +2392,7 @@ renderHeader();
renderMap();
renderPanel();
renderLldp();
+renderNeighbor();
renderInterfaces();
renderSfp();
renderAlarms();
diff --git a/cisco-parse.py b/cisco-parse.py
new file mode 100644
index 0000000..68a0f5f
--- /dev/null
+++ b/cisco-parse.py
@@ -0,0 +1,734 @@
+#!/usr/bin/env python3
+"""
+Cisco SNMP Walk Parser
+
+Parses raw snmpbulkwalk -On -OQ output from a Cisco device and produces
+a structured JSON file with interface details focused on a specific
+remPortId (LLDP neighbor interface).
+
+Usage:
+ python3 cisco-parse.py [output.json]
+
+Examples:
+ python3 cisco-parse.py walks/172-16-50-4_walk.txt Te1/1/3
+ python3 cisco-parse.py walks/172-16-50-4_walk.txt Gi1/0/39 output.json
+"""
+
+import json
+import re
+import sys
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Cisco short-name <-> long-name mapping
+# ────────────────────────────────────────────────────────────────────────
+
+CISCO_SHORT_TO_LONG = {
+ "Te": "TenGigabitEthernet",
+ "Gi": "GigabitEthernet",
+ "Fa": "FastEthernet",
+ "Hu": "HundredGigE",
+ "Tw": "TwentyFiveGigE",
+ "Fo": "FortyGigabitEthernet",
+ "Eth": "Ethernet",
+ "BDI": "BDI",
+ "BVI": "BVI",
+ "Lo": "Loopback",
+ "Vl": "Vlan",
+ "Po": "Port-channel",
+ "Twe": "TwentyFiveGigE",
+}
+
+# Reverse map: long -> short (built from the canonical mapping)
+CISCO_LONG_TO_SHORT = {}
+for _short, _long in CISCO_SHORT_TO_LONG.items():
+ # If multiple shorts map to the same long, prefer the shorter abbreviation
+ if _long not in CISCO_LONG_TO_SHORT or len(_short) < len(CISCO_LONG_TO_SHORT[_long]):
+ CISCO_LONG_TO_SHORT[_long] = _short
+
+
+# ────────────────────────────────────────────────────────────────────────
+# OID constants
+# ────────────────────────────────────────────────────────────────────────
+
+OID_SYS_DESCR = ".1.3.6.1.2.1.1.1.0"
+OID_SYS_UPTIME = ".1.3.6.1.2.1.1.3.0"
+OID_SYS_NAME = ".1.3.6.1.2.1.1.5.0"
+
+# IF-MIB base prefixes (append .{ifIndex})
+OID_IF_DESCR = ".1.3.6.1.2.1.2.2.1.2"
+OID_IF_TYPE = ".1.3.6.1.2.1.2.2.1.3"
+OID_IF_MTU = ".1.3.6.1.2.1.2.2.1.4"
+OID_IF_SPEED = ".1.3.6.1.2.1.2.2.1.5"
+OID_IF_ADMIN_STATUS = ".1.3.6.1.2.1.2.2.1.7"
+OID_IF_OPER_STATUS = ".1.3.6.1.2.1.2.2.1.8"
+OID_IF_IN_OCTETS = ".1.3.6.1.2.1.2.2.1.10"
+OID_IF_IN_DISCARDS = ".1.3.6.1.2.1.2.2.1.13"
+OID_IF_IN_ERRORS = ".1.3.6.1.2.1.2.2.1.14"
+OID_IF_OUT_OCTETS = ".1.3.6.1.2.1.2.2.1.16"
+OID_IF_OUT_DISCARDS = ".1.3.6.1.2.1.2.2.1.19"
+OID_IF_OUT_ERRORS = ".1.3.6.1.2.1.2.2.1.20"
+
+# IF-MIB ifXTable
+OID_IF_NAME = ".1.3.6.1.2.1.31.1.1.1.1"
+OID_IF_HC_IN_OCTETS = ".1.3.6.1.2.1.31.1.1.1.6"
+OID_IF_HC_OUT_OCTETS = ".1.3.6.1.2.1.31.1.1.1.10"
+OID_IF_HIGH_SPEED = ".1.3.6.1.2.1.31.1.1.1.15"
+OID_IF_ALIAS = ".1.3.6.1.2.1.31.1.1.1.18"
+
+# ifStackTable: .1.3.6.1.2.1.31.1.2.1.3.{higher}.{lower}
+OID_IF_STACK_STATUS = ".1.3.6.1.2.1.31.1.2.1.3"
+
+# ENTITY-MIB
+OID_ENT_PHYS_PREFIX = ".1.3.6.1.2.1.47.1.1.1.1"
+
+# CISCO-ENTITY-SENSOR-MIB
+OID_CISCO_SENSOR_PREFIX = ".1.3.6.1.4.1.9.9.91.1.1.1.1"
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Walk file parser
+# ────────────────────────────────────────────────────────────────────────
+
+def parse_walk_file(walk_file):
+ """Parse an snmpbulkwalk -On -OQ output file into {oid: value} dict.
+
+ Lines look like:
+ .1.3.6.1.2.1.2.2.1.2.62 = "TenGigabitEthernet1/1/3"
+ .1.3.6.1.2.1.2.2.1.7.62 = 1
+
+ String values have surrounding quotes stripped.
+ """
+ walk_path = Path(walk_file)
+ oid_data = {}
+
+ with walk_path.open("r", errors="replace") as fh:
+ pending_oid = None
+ pending_val = None
+
+ for raw_line in fh:
+ line = raw_line.rstrip("\n\r")
+
+ # Handle multi-line values (e.g., Cisco sysDescr spans multiple lines)
+ if pending_oid is not None:
+ # Continuation of a multi-line quoted value
+ pending_val += " " + line.strip()
+ if '"' in line:
+ # Closing quote found — finalize
+ val = pending_val.strip()
+ if val.startswith('"'):
+ val = val[1:]
+ if val.endswith('"'):
+ val = val[:-1]
+ oid_data[pending_oid] = val
+ pending_oid = None
+ pending_val = None
+ continue
+
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+
+ # Split on first ' = '
+ parts = line.split(" = ", 1)
+ if len(parts) != 2:
+ continue
+
+ oid = parts[0].strip()
+ value = parts[1].strip()
+
+ # Check for opening quote without closing (multi-line value)
+ if value.startswith('"') and not value.endswith('"'):
+ pending_oid = oid
+ pending_val = value
+ continue
+
+ # Strip surrounding quotes
+ if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
+ value = value[1:-1]
+
+ oid_data[oid] = value
+
+ # Handle any trailing pending value
+ if pending_oid is not None:
+ val = pending_val.strip().strip('"')
+ oid_data[pending_oid] = val
+
+ return oid_data
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Helpers
+# ────────────────────────────────────────────────────────────────────────
+
+def _get(oid_data, oid, default=""):
+ """Safely retrieve a value from oid_data."""
+ return oid_data.get(oid, default)
+
+
+def _get_subtree(oid_data, prefix):
+ """Return all entries whose OID starts with prefix (with trailing dot)."""
+ dotted = prefix if prefix.endswith(".") else prefix + "."
+ return {k: v for k, v in oid_data.items() if k.startswith(dotted) or k == prefix}
+
+
+def _expand_short_name(short_name):
+ """Expand a Cisco short interface name to full form.
+
+ 'Te1/1/3' -> 'TenGigabitEthernet1/1/3'
+ 'Gi1/0/39' -> 'GigabitEthernet1/0/39'
+ """
+ # Try each prefix, longest first to avoid partial matches (e.g., Twe before Tw)
+ for short in sorted(CISCO_SHORT_TO_LONG.keys(), key=len, reverse=True):
+ if short_name.startswith(short):
+ remainder = short_name[len(short):]
+ # Verify the remainder starts with a digit or slash (real interface numbering)
+ if remainder and (remainder[0].isdigit() or remainder[0] == "/"):
+ return CISCO_SHORT_TO_LONG[short] + remainder
+ return short_name
+
+
+def _shorten_name(long_name):
+ """Shorten a Cisco full interface name to abbreviated form.
+
+ 'TenGigabitEthernet1/1/3' -> 'Te1/1/3'
+ """
+ for long, short in sorted(CISCO_LONG_TO_SHORT.items(), key=lambda x: len(x[0]), reverse=True):
+ if long_name.startswith(long):
+ return short + long_name[len(long):]
+ return long_name
+
+
+# ────────────────────────────────────────────────────────────────────────
+# System info extraction
+# ────────────────────────────────────────────────────────────────────────
+
+def get_system_info(oid_data):
+ """Extract system-level information."""
+ sys_descr = _get(oid_data, OID_SYS_DESCR)
+ sys_uptime = _get(oid_data, OID_SYS_UPTIME)
+ sys_name = _get(oid_data, OID_SYS_NAME)
+
+ os_type = "unknown"
+ platform = ""
+
+ if "IOS-XR" in sys_descr:
+ os_type = "IOS-XR"
+ # Try to extract version
+ ver_match = re.search(r"Version\s+([\d.]+)", sys_descr)
+ version = ver_match.group(1) if ver_match else ""
+ # Try to extract platform from sysDescr
+ plat_match = re.search(r"Cisco\s+(\S+)", sys_descr)
+ plat_name = plat_match.group(1) if plat_match else "Cisco"
+ platform = f"Cisco {plat_name} IOS-XR {version}".strip()
+ elif "IOS Software" in sys_descr or "Cisco IOS" in sys_descr:
+ os_type = "IOS-XE"
+ # Try to extract version
+ ver_match = re.search(r"Version\s+([\d.()A-Za-z]+)", sys_descr)
+ version = ver_match.group(1) if ver_match else ""
+ # Try to extract platform (e.g., CAT3K, C3850, Catalyst)
+ plat_match = re.search(r"(CAT\w+|C\d\w+|Catalyst\s+\S+)", sys_descr)
+ if not plat_match:
+ plat_match = re.search(r"Cisco\s+(\S+)", sys_descr)
+ plat_name = plat_match.group(1) if plat_match else "Cisco"
+ platform = f"Cisco {plat_name} IOS-XE {version}".strip()
+
+ return {
+ "sysName": sys_name,
+ "sysDescr": sys_descr,
+ "sysUpTime": sys_uptime,
+ "platform": platform,
+ "osType": os_type,
+ }
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Interface index builder
+# ────────────────────────────────────────────────────────────────────────
+
+def build_interface_index(oid_data):
+ """Build a dict of ifIndex -> {ifDescr, ifName, ...} from walk data."""
+ interfaces = {}
+
+ # Collect ifDescr entries
+ descr_prefix = OID_IF_DESCR + "."
+ for oid, value in oid_data.items():
+ if oid.startswith(descr_prefix):
+ ifindex = oid[len(descr_prefix):]
+ if ifindex not in interfaces:
+ interfaces[ifindex] = {}
+ interfaces[ifindex]["ifDescr"] = value
+
+ # Collect ifName entries
+ name_prefix = OID_IF_NAME + "."
+ for oid, value in oid_data.items():
+ if oid.startswith(name_prefix):
+ ifindex = oid[len(name_prefix):]
+ if ifindex not in interfaces:
+ interfaces[ifindex] = {}
+ interfaces[ifindex]["ifName"] = value
+
+ return interfaces
+
+
+# ────────────────────────────────────────────────────────────────────────
+# remPortId matching
+# ────────────────────────────────────────────────────────────────────────
+
+def match_rem_port_id(interfaces, rem_port_id):
+ """Match a remPortId string to an ifIndex.
+
+ Tries:
+ 1. Exact match against ifName values
+ 2. Expand short name and match against ifDescr
+ 3. Direct match against ifDescr (already long form)
+
+ Returns ifIndex as string, or None.
+ """
+ # 1. Exact match against ifName
+ for ifindex, info in interfaces.items():
+ if info.get("ifName", "") == rem_port_id:
+ _dbg(f"Matched remPortId '{rem_port_id}' via ifName -> ifIndex {ifindex}")
+ return ifindex
+
+ # 2. Expand short name and match ifDescr
+ expanded = _expand_short_name(rem_port_id)
+ if expanded != rem_port_id:
+ for ifindex, info in interfaces.items():
+ if info.get("ifDescr", "") == expanded:
+ _dbg(f"Matched remPortId '{rem_port_id}' (expanded: '{expanded}') via ifDescr -> ifIndex {ifindex}")
+ return ifindex
+
+ # 3. Direct match against ifDescr
+ for ifindex, info in interfaces.items():
+ if info.get("ifDescr", "") == rem_port_id:
+ _dbg(f"Matched remPortId '{rem_port_id}' via ifDescr direct -> ifIndex {ifindex}")
+ return ifindex
+
+ _dbg(f"WARNING: Could not match remPortId '{rem_port_id}' to any interface")
+ return None
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Interface facts extraction
+# ────────────────────────────────────────────────────────────────────────
+
+def get_interface_facts(oid_data, ifindex):
+ """Extract all relevant facts for a given ifIndex."""
+ idx = str(ifindex)
+
+ return {
+ "ifDescr": _get(oid_data, f"{OID_IF_DESCR}.{idx}"),
+ "ifType": _get(oid_data, f"{OID_IF_TYPE}.{idx}"),
+ "ifMtu": _get(oid_data, f"{OID_IF_MTU}.{idx}"),
+ "ifSpeed": _get(oid_data, f"{OID_IF_SPEED}.{idx}"),
+ "ifAdminStatus": _get(oid_data, f"{OID_IF_ADMIN_STATUS}.{idx}"),
+ "ifOperStatus": _get(oid_data, f"{OID_IF_OPER_STATUS}.{idx}"),
+ "ifInOctets": _get(oid_data, f"{OID_IF_IN_OCTETS}.{idx}"),
+ "ifInErrors": _get(oid_data, f"{OID_IF_IN_ERRORS}.{idx}"),
+ "ifOutOctets": _get(oid_data, f"{OID_IF_OUT_OCTETS}.{idx}"),
+ "ifOutErrors": _get(oid_data, f"{OID_IF_OUT_ERRORS}.{idx}"),
+ "ifInDiscards": _get(oid_data, f"{OID_IF_IN_DISCARDS}.{idx}"),
+ "ifOutDiscards": _get(oid_data, f"{OID_IF_OUT_DISCARDS}.{idx}"),
+ "ifName": _get(oid_data, f"{OID_IF_NAME}.{idx}"),
+ "ifHighSpeed": _get(oid_data, f"{OID_IF_HIGH_SPEED}.{idx}"),
+ "ifAlias": _get(oid_data, f"{OID_IF_ALIAS}.{idx}"),
+ "ifHCInOctets": _get(oid_data, f"{OID_IF_HC_IN_OCTETS}.{idx}"),
+ "ifHCOutOctets": _get(oid_data, f"{OID_IF_HC_OUT_OCTETS}.{idx}"),
+ }
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Subinterface discovery
+# ────────────────────────────────────────────────────────────────────────
+
+def discover_subinterfaces_stack(oid_data, parent_ifindex):
+ """Discover child interfaces via ifStackTable.
+
+ In ifStackTable, .1.3.6.1.2.1.31.1.2.1.3.{higher}.{lower} = status
+ When lower == parent_ifindex, higher is a child/sub-interface layered
+ on top of the parent. Skip ifIndex 0 (represents 'not stacked').
+ """
+ children = set()
+ prefix = OID_IF_STACK_STATUS + "."
+ parent_suffix = f".{parent_ifindex}"
+
+ for oid in oid_data:
+ if not oid.startswith(prefix):
+ continue
+ remainder = oid[len(prefix):]
+ parts = remainder.split(".", 1)
+ if len(parts) != 2:
+ continue
+ higher, lower = parts[0], parts[1]
+ if lower == str(parent_ifindex) and higher != "0":
+ children.add(higher)
+
+ return children
+
+
+def discover_subinterfaces_pattern(oid_data, parent_descr):
+ """Discover subinterfaces by name pattern matching.
+
+ Look for ifDescr values like '{parent_descr}.{number}'.
+ """
+ children = {}
+ prefix = OID_IF_DESCR + "."
+ pattern = parent_descr + "."
+
+ for oid, value in oid_data.items():
+ if not oid.startswith(prefix):
+ continue
+ if value.startswith(pattern):
+ ifindex = oid[len(prefix):]
+ children[ifindex] = value
+
+ return children
+
+
+def discover_subinterfaces(oid_data, parent_ifindex, parent_descr):
+ """Combine stack-based and pattern-based subinterface discovery."""
+ # Approach A: ifStackTable
+ stack_children = discover_subinterfaces_stack(oid_data, parent_ifindex)
+
+ # Approach B: pattern matching
+ pattern_children = discover_subinterfaces_pattern(oid_data, parent_descr)
+
+ # Merge: union of both sets of ifIndex values
+ all_child_indices = set(stack_children) | set(pattern_children.keys())
+
+ return all_child_indices
+
+
+# ────────────────────────────────────────────────────────────────────────
+# VLAN / SVI discovery (IOS-XE)
+# ────────────────────────────────────────────────────────────────────────
+
+def discover_vlans(oid_data, os_type):
+ """Discover VLAN SVIs for IOS-XE devices.
+
+ On IOS-XE Catalyst switches, scan ifDescr for 'Vlan{N}' entries
+ and collect their interface facts.
+ """
+ vlans = {}
+
+ if os_type != "IOS-XE":
+ return vlans
+
+ prefix = OID_IF_DESCR + "."
+ vlan_re = re.compile(r"^Vlan(\d+)$")
+
+ for oid, value in oid_data.items():
+ if not oid.startswith(prefix):
+ continue
+ m = vlan_re.match(value)
+ if not m:
+ continue
+
+ vlan_num = m.group(1)
+ ifindex = oid[len(prefix):]
+
+ vlans[vlan_num] = {
+ "ifIndex": ifindex,
+ "ifDescr": value,
+ "ifAlias": _get(oid_data, f"{OID_IF_ALIAS}.{ifindex}"),
+ "ifAdminStatus": _get(oid_data, f"{OID_IF_ADMIN_STATUS}.{ifindex}"),
+ "ifOperStatus": _get(oid_data, f"{OID_IF_OPER_STATUS}.{ifindex}"),
+ }
+
+ return vlans
+
+
+# ────────────────────────────────────────────────────────────────────────
+# BDI / BVI correlation
+# ────────────────────────────────────────────────────────────────────────
+
+def find_bdi_bvi(oid_data, vlan_id):
+ """Find a BDI{vlan_id} or BVI{vlan_id} interface if it exists.
+
+ Returns (ifIndex, ifDescr, ifOperStatus, ifAlias) or (None, None, None, None).
+ """
+ prefix = OID_IF_DESCR + "."
+ targets = [f"BDI{vlan_id}", f"BVI{vlan_id}"]
+
+ for oid, value in oid_data.items():
+ if not oid.startswith(prefix):
+ continue
+ if value in targets:
+ ifindex = oid[len(prefix):]
+ return (
+ ifindex,
+ value,
+ _get(oid_data, f"{OID_IF_OPER_STATUS}.{ifindex}"),
+ _get(oid_data, f"{OID_IF_ALIAS}.{ifindex}"),
+ )
+
+ return (None, None, None, None)
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Optics / Entity Sensor (best-effort)
+# ────────────────────────────────────────────────────────────────────────
+
+def get_optics_info(oid_data, parent_ifindex, parent_descr):
+ """Best-effort extraction of optics data from ENTITY-MIB and
+ CISCO-ENTITY-SENSOR-MIB.
+
+ Strategy:
+ 1. Walk entPhysicalName (.1.3.6.1.2.1.47.1.1.1.1.7) to find entities
+ whose name contains the interface name/description.
+ 2. For those entity indices, look up sensor values from
+ CISCO-ENTITY-SENSOR-MIB (.1.3.6.1.4.1.9.9.91.1.1.1.1.4.{entIdx}).
+ 3. Use entPhysicalDescr or entSensorType to classify as Tx/Rx/temp.
+
+ Returns dict with txPower, rxPower, temperature (all may be None).
+ """
+ result = {
+ "txPower": None,
+ "rxPower": None,
+ "temperature": None,
+ }
+
+ # entPhysicalName: .1.3.6.1.2.1.47.1.1.1.1.7.{entIdx}
+ ent_name_prefix = OID_ENT_PHYS_PREFIX.rsplit(".", 1)[0] + ".7."
+ # Actually: .1.3.6.1.2.1.47.1.1.1.1.7.{idx}
+ ent_name_prefix = ".1.3.6.1.2.1.47.1.1.1.1.7."
+
+ # entPhysicalDescr: .1.3.6.1.2.1.47.1.1.1.1.2.{idx}
+ ent_descr_prefix = ".1.3.6.1.2.1.47.1.1.1.1.2."
+
+ # entSensorValue: .1.3.6.1.4.1.9.9.91.1.1.1.1.4.{idx}
+ sensor_value_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.4."
+
+ # entSensorType: .1.3.6.1.4.1.9.9.91.1.1.1.1.1.{idx}
+ sensor_type_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.1."
+
+ # Find entity indices that match the interface
+ short_name = _shorten_name(parent_descr)
+ matching_ent_indices = []
+
+ for oid, value in oid_data.items():
+ if not oid.startswith(ent_name_prefix):
+ continue
+ # Check if entity name references our interface
+ if parent_descr in value or short_name in value:
+ ent_idx = oid[len(ent_name_prefix):]
+ matching_ent_indices.append(ent_idx)
+
+ if not matching_ent_indices:
+ return result
+
+ _dbg(f"Found {len(matching_ent_indices)} entity entries for {parent_descr}")
+
+ # For matching entities, look up sensor readings
+ for ent_idx in matching_ent_indices:
+ sensor_val = _get(oid_data, f"{sensor_value_prefix}{ent_idx}")
+ if not sensor_val:
+ continue
+
+ # Determine sensor type from entSensorType or entPhysicalDescr
+ sensor_type = _get(oid_data, f"{sensor_type_prefix}{ent_idx}")
+ ent_descr = _get(oid_data, f"{ent_descr_prefix}{ent_idx}").lower()
+
+ # entSensorType: 8 = celsius, 14 = dBm
+ # Also check description text for classification
+ if sensor_type == "8" or "temperature" in ent_descr or "temp" in ent_descr:
+ result["temperature"] = sensor_val
+ elif "transmit" in ent_descr or "tx" in ent_descr:
+ result["txPower"] = sensor_val
+ elif "receive" in ent_descr or "rx" in ent_descr:
+ result["rxPower"] = sensor_val
+ elif sensor_type == "14":
+ # dBm but unclassified — assign to first empty power slot
+ if result["txPower"] is None:
+ result["txPower"] = sensor_val
+ elif result["rxPower"] is None:
+ result["rxPower"] = sensor_val
+
+ return result
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Debug helper
+# ────────────────────────────────────────────────────────────────────────
+
+def _dbg(msg):
+ """Print debug/progress info to stderr."""
+ print(f"[cisco-parse] {msg}", file=sys.stderr)
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Main builder
+# ────────────────────────────────────────────────────────────────────────
+
+def build_neighbor_output(oid_data, rem_port_id):
+ """Build the structured neighbor output from parsed OID data.
+
+ Args:
+ oid_data: dict from parse_walk_file()
+ rem_port_id: interface identifier to focus on (e.g., 'Te1/1/3')
+
+ Returns:
+ dict matching the output JSON structure
+ """
+ # System info
+ sys_info = get_system_info(oid_data)
+ _dbg(f"System: {sys_info['sysName']} ({sys_info['osType']})")
+
+ # Build interface index
+ interfaces = build_interface_index(oid_data)
+ _dbg(f"Found {len(interfaces)} interfaces in walk data")
+
+ # Match remPortId to ifIndex
+ matched_ifindex = match_rem_port_id(interfaces, rem_port_id)
+
+ # Build queried interface section
+ queried_iface = {"remPortId": rem_port_id}
+ parent_descr = ""
+
+ if matched_ifindex is not None:
+ facts = get_interface_facts(oid_data, matched_ifindex)
+ parent_descr = facts.get("ifDescr", "")
+ queried_iface.update({
+ "ifIndex": matched_ifindex,
+ "ifDescr": facts["ifDescr"],
+ "ifName": facts["ifName"],
+ "ifAlias": facts["ifAlias"],
+ "ifType": facts["ifType"],
+ "ifAdminStatus": facts["ifAdminStatus"],
+ "ifOperStatus": facts["ifOperStatus"],
+ "ifSpeed": facts["ifSpeed"],
+ "ifHighSpeed": facts["ifHighSpeed"],
+ "ifMtu": facts["ifMtu"],
+ "ifHCInOctets": facts["ifHCInOctets"],
+ "ifHCOutOctets": facts["ifHCOutOctets"],
+ "ifInErrors": facts["ifInErrors"],
+ "ifOutErrors": facts["ifOutErrors"],
+ "ifInDiscards": facts["ifInDiscards"],
+ "ifOutDiscards": facts["ifOutDiscards"],
+ })
+ _dbg(f"Queried interface: {parent_descr} (ifIndex {matched_ifindex})")
+ else:
+ # Fill with empty values so the JSON schema is consistent
+ for key in ("ifIndex", "ifDescr", "ifName", "ifAlias", "ifType",
+ "ifAdminStatus", "ifOperStatus", "ifSpeed", "ifHighSpeed",
+ "ifMtu", "ifHCInOctets", "ifHCOutOctets", "ifInErrors",
+ "ifOutErrors", "ifInDiscards", "ifOutDiscards"):
+ queried_iface[key] = ""
+ _dbg("WARNING: No interface matched, output will have empty queried_interface")
+
+ # Discover subinterfaces
+ subinterfaces = {}
+ if matched_ifindex and parent_descr:
+ child_indices = discover_subinterfaces(oid_data, matched_ifindex, parent_descr)
+ _dbg(f"Found {len(child_indices)} subinterfaces")
+
+ for child_idx in sorted(child_indices, key=lambda x: int(x) if x.isdigit() else 0):
+ child_facts = get_interface_facts(oid_data, child_idx)
+ child_descr = child_facts.get("ifDescr", "")
+
+ # Extract VLAN ID from .NNNN suffix
+ vlan_id = ""
+ vlan_match = re.search(r"\.(\d+)$", child_descr)
+ if vlan_match:
+ vlan_id = vlan_match.group(1)
+
+ # BDI/BVI correlation
+ bvi_ifindex, bvi_descr, bvi_oper, bvi_alias = (None, None, None, None)
+ if vlan_id:
+ bvi_ifindex, bvi_descr, bvi_oper, bvi_alias = find_bdi_bvi(oid_data, vlan_id)
+
+ subinterfaces[child_idx] = {
+ "ifDescr": child_descr,
+ "ifName": child_facts.get("ifName", ""),
+ "ifAlias": child_facts.get("ifAlias", ""),
+ "ifAdminStatus": child_facts.get("ifAdminStatus", ""),
+ "ifOperStatus": child_facts.get("ifOperStatus", ""),
+ "vlanId": vlan_id,
+ "bvi_ifIndex": bvi_ifindex,
+ "bvi_ifDescr": bvi_descr,
+ "bvi_ifOperStatus": bvi_oper,
+ "bvi_ifAlias": bvi_alias,
+ }
+
+ # Discover VLANs (IOS-XE)
+ vlans = discover_vlans(oid_data, sys_info["osType"])
+ _dbg(f"Found {len(vlans)} VLAN SVIs")
+
+ # Optics (best-effort)
+ optics = {"txPower": None, "rxPower": None, "temperature": None}
+ if matched_ifindex and parent_descr:
+ optics = get_optics_info(oid_data, matched_ifindex, parent_descr)
+
+ # Assemble output
+ output = {
+ "neighbor_system": sys_info,
+ "queried_interface": queried_iface,
+ "subinterfaces": subinterfaces,
+ "vlans": vlans,
+ "optics": optics,
+ "_meta": {
+ "polled_at": datetime.now(timezone.utc).isoformat(),
+ "target_ip": "",
+ "walk_lines": 0,
+ "elapsed_sec": 0.0,
+ "remPortId": rem_port_id,
+ },
+ }
+
+ return output
+
+
+# ────────────────────────────────────────────────────────────────────────
+# CLI entry point
+# ────────────────────────────────────────────────────────────────────────
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: cisco-parse.py [output.json]")
+ print()
+ print("Arguments:")
+ print(" walk_file Raw snmpbulkwalk -On -OQ output file")
+ print(" remPortId Interface to focus on (e.g., Te1/1/3, Gi1/0/39)")
+ print(" output.json Optional output path (default: {stem}_neighbor_monitoring.json)")
+ sys.exit(1)
+
+ walk_file = Path(sys.argv[1])
+ rem_port_id = sys.argv[2]
+ output_path = (
+ Path(sys.argv[3])
+ if len(sys.argv) > 3
+ else walk_file.with_name(walk_file.stem + "_neighbor_monitoring.json")
+ )
+
+ if not walk_file.exists():
+ print(f"Error: walk file not found: {walk_file}", file=sys.stderr)
+ sys.exit(1)
+
+ _dbg(f"Parsing {walk_file}")
+ _dbg(f"Looking for remPortId: {rem_port_id}")
+
+ t_start = time.time()
+ oid_data = parse_walk_file(walk_file)
+ _dbg(f"Parsed {len(oid_data)} OID entries")
+
+ result = build_neighbor_output(oid_data, rem_port_id)
+ result["_meta"]["walk_lines"] = len(oid_data)
+ result["_meta"]["elapsed_sec"] = round(time.time() - t_start, 2)
+
+ # Try to extract target IP from filename pattern: {IP}_{timestamp}_..._walk.txt
+ # e.g., 172-16-50-4_2026-03-01_10-00-00_neighbor_walk.txt
+ name = walk_file.stem
+ ip_match = re.match(r"([\d-]+)_", name)
+ if ip_match:
+ result["_meta"]["target_ip"] = ip_match.group(1).replace("-", ".")
+
+ output_path.write_text(json.dumps(result, indent=2))
+ print(f"Wrote {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/nid-server.py b/nid-server.py
index eab23d4..70090af 100644
--- a/nid-server.py
+++ b/nid-server.py
@@ -30,6 +30,18 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
WALKS_DIR = SCRIPT_DIR / "walks"
+# ── OID subtrees for neighbor (Cisco) device walk ────────────────────
+NEIGHBOR_TARGETED_OIDS = [
+ (".1.3.6.1.2.1.1", "System"),
+ (".1.3.6.1.2.1.2.2.1", "ifTable"),
+ (".1.3.6.1.2.1.31.1.1.1", "ifXTable"),
+ (".1.3.6.1.2.1.31.1.2", "ifStackTable"),
+ (".1.3.6.1.2.1.17.7.1.4.3.1", "dot1qVlanStatic"),
+ (".1.3.6.1.4.1.9.9.46.1.3.1", "vtpVlanTable"),
+ (".1.3.6.1.2.1.47.1.1.1", "entPhysicalTable"),
+ (".1.3.6.1.4.1.9.9.91.1.1.1", "ciscoEntitySensor"),
+]
+
# ── OID subtrees for targeted walk (mirrors snmp-walk.sh) ────────────
TARGETED_OIDS = [
(".1.3.6.1.2.1.1", "System"),
@@ -82,6 +94,10 @@ SNMP_V3_SEC_LEVEL = ENV.get("SNMP_V3_SEC_LEVEL", "authPriv")
if ENV.get("SNMP_WALK_POLICIES", "true").lower() == "true":
TARGETED_OIDS.append((".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB"))
+# Neighbor device SNMP credentials (falls back to NID creds)
+NEIGHBOR_SNMP_VERSION = ENV.get("NEIGHBOR_SNMP_VERSION", SNMP_VERSION)
+NEIGHBOR_SNMP_COMMUNITY = ENV.get("NEIGHBOR_SNMP_COMMUNITY", SNMP_COMMUNITY)
+
# ── Walk state (shared across threads) ───────────────────────────────
walk_lock = threading.Lock()
@@ -98,6 +114,11 @@ walk_version = 0
# Path to latest monitoring JSON (set after successful walk)
latest_json = None
+# Neighbor walk state (independent from NID walk)
+neighbor_lock = threading.Lock()
+neighbor_status = {} # keyed by target IP: {"state": ..., "message": ..., "json_path": ...}
+latest_neighbor = {} # keyed by target IP: path to latest neighbor monitoring JSON
+
def set_status(state, message="", progress=0, **extra):
global walk_version
@@ -232,6 +253,123 @@ def run_walk(target: str, mode: str, policies: bool = True):
set_status("error", message=str(e)[:300])
+# ── Neighbor device walk ──────────────────────────────────────────────
+
+def build_neighbor_snmp_auth() -> list:
+ """Build snmpwalk auth flags for neighbor device (falls back to NID creds)."""
+ if NEIGHBOR_SNMP_VERSION == "3":
+ # Future: support v3 for neighbor
+ return build_snmp_auth()
+ return ["-v", NEIGHBOR_SNMP_VERSION, "-c", NEIGHBOR_SNMP_COMMUNITY]
+
+
+def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
+ """Execute a targeted SNMP walk against an LLDP neighbor device."""
+ ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
+ if not ip_re.match(target):
+ with neighbor_lock:
+ neighbor_status[target] = {"state": "error", "message": f"Invalid IP: {target}"}
+ return
+
+ with neighbor_lock:
+ neighbor_status[target] = {"state": "walking", "message": "Starting neighbor walk..."}
+
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ safe_ip = target.replace(".", "-")
+ walk_file = WALKS_DIR / f"{safe_ip}_{timestamp}_neighbor_walk.txt"
+ WALKS_DIR.mkdir(parents=True, exist_ok=True)
+
+ auth = build_neighbor_snmp_auth()
+ walk_cmd = "snmpbulkwalk" if shutil.which("snmpbulkwalk") else "snmpwalk"
+ t_start = time.time()
+
+ try:
+ # Walk neighbor subtrees in parallel
+ total = len(NEIGHBOR_TARGETED_OIDS)
+ completed = [0]
+ results_map = {}
+
+ def walk_subtree(idx, oid, label):
+ try:
+ res = subprocess.run(
+ [walk_cmd, "-On", "-OQ"] + auth + [target, oid],
+ capture_output=True, text=True, timeout=60,
+ )
+ completed[0] += 1
+ with neighbor_lock:
+ neighbor_status[target] = {
+ "state": "walking",
+ "message": f"Walking subtrees ({completed[0]}/{total})",
+ }
+ return idx, res.stdout
+ except subprocess.TimeoutExpired:
+ completed[0] += 1
+ return idx, ""
+
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ futures = [
+ pool.submit(walk_subtree, i, oid, label)
+ for i, (oid, label) in enumerate(NEIGHBOR_TARGETED_OIDS)
+ ]
+ for fut in as_completed(futures):
+ idx, output = fut.result()
+ if output.strip():
+ results_map[idx] = output
+
+ output_lines = [results_map[i] for i in sorted(results_map)]
+ walk_file.write_text("\n".join(output_lines))
+
+ line_count = sum(1 for _ in walk_file.open())
+ elapsed = round(time.time() - t_start, 1)
+
+ if line_count == 0:
+ with neighbor_lock:
+ neighbor_status[target] = {
+ "state": "error",
+ "message": "Walk returned no data — check credentials",
+ }
+ return
+
+ # Parse with cisco-parse.py
+ with neighbor_lock:
+ neighbor_status[target] = {"state": "parsing", "message": "Parsing neighbor data..."}
+
+ parse_result = subprocess.run(
+ [sys.executable, str(SCRIPT_DIR / "cisco-parse.py"),
+ str(walk_file), rem_port_id],
+ capture_output=True, text=True, timeout=60,
+ )
+
+ if parse_result.returncode != 0:
+ with neighbor_lock:
+ neighbor_status[target] = {
+ "state": "error",
+ "message": f"Parse failed: {parse_result.stderr[:200]}",
+ }
+ return
+
+ neighbor_json = walk_file.with_name(walk_file.stem + "_neighbor_monitoring.json")
+ if not neighbor_json.is_file():
+ with neighbor_lock:
+ neighbor_status[target] = {
+ "state": "error",
+ "message": "Parser did not produce neighbor JSON",
+ }
+ return
+
+ with neighbor_lock:
+ latest_neighbor[target] = neighbor_json
+ neighbor_status[target] = {
+ "state": "complete",
+ "message": f"Done — {line_count:,} lines in {elapsed}s",
+ "json_path": str(neighbor_json),
+ }
+
+ except Exception as e:
+ with neighbor_lock:
+ neighbor_status[target] = {"state": "error", "message": str(e)[:300]}
+
+
# ── Find latest monitoring JSON ──────────────────────────────────────
def find_latest_json() -> Path | None:
@@ -269,6 +407,10 @@ class NIDHandler(BaseHTTPRequestHandler):
self._serve_viewer()
elif self.path == "/api/status":
self._serve_sse()
+ elif self.path.startswith("/api/neighbor-data"):
+ self._handle_neighbor_data()
+ elif self.path.startswith("/api/neighbor-status"):
+ self._handle_neighbor_status()
else:
self._send(404, "text/plain", b"Not found")
@@ -285,6 +427,20 @@ class NIDHandler(BaseHTTPRequestHandler):
else:
data = {}
+ # Merge any available neighbor data into the viewer data
+ with neighbor_lock:
+ if latest_neighbor:
+ nd = {}
+ for ip, npath in latest_neighbor.items():
+ if npath and npath.is_file():
+ try:
+ with npath.open() as f:
+ nd[ip] = json.load(f)
+ except Exception:
+ pass
+ if nd:
+ data["neighbor_data"] = nd
+
html = build_html(data)
self._send(200, "text/html; charset=utf-8", html.encode())
@@ -321,6 +477,8 @@ class NIDHandler(BaseHTTPRequestHandler):
self._handle_ping()
elif self.path == "/api/clear":
self._handle_clear()
+ elif self.path == "/api/neighbor-walk":
+ self._handle_neighbor_walk()
else:
self._send(404, "text/plain", b"Not found")
@@ -375,6 +533,75 @@ class NIDHandler(BaseHTTPRequestHandler):
thread.start()
self._send_json(200, {"status": "started", "target": target, "mode": mode})
+ def _handle_neighbor_walk(self):
+ """Start a neighbor device walk in a background thread."""
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length)) if length else {}
+ target = body.get("target", "").strip()
+ rem_port_id = body.get("remPortId", "").strip()
+ rem_sys_name = body.get("remSysName", "").strip()
+
+ if not target or not rem_port_id:
+ self._send_json(400, {"error": "target and remPortId 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
+
+ # Check if already walking this neighbor
+ with neighbor_lock:
+ ns = neighbor_status.get(target, {})
+ if ns.get("state") == "walking":
+ self._send_json(409, {"error": f"Already walking {target}"})
+ return
+
+ thread = threading.Thread(
+ target=run_neighbor_walk,
+ args=(target, rem_port_id, rem_sys_name),
+ daemon=True,
+ )
+ thread.start()
+ self._send_json(200, {"status": "started", "target": target})
+
+ def _handle_neighbor_data(self):
+ """Return the latest neighbor monitoring JSON for a given target IP."""
+ # Parse ?target=x.x.x.x from query string
+ from urllib.parse import urlparse, parse_qs
+ qs = parse_qs(urlparse(self.path).query)
+ target = qs.get("target", [None])[0]
+
+ if not target:
+ self._send_json(400, {"error": "target query param required"})
+ return
+
+ with neighbor_lock:
+ json_path = latest_neighbor.get(target)
+
+ if not json_path or not json_path.is_file():
+ self._send_json(404, {"error": f"No neighbor data for {target}"})
+ return
+
+ with json_path.open() as f:
+ data = json.load(f)
+ self._send_json(200, data)
+
+ def _handle_neighbor_status(self):
+ """Return the current walk status for a neighbor target."""
+ from urllib.parse import urlparse, parse_qs
+ qs = parse_qs(urlparse(self.path).query)
+ target = qs.get("target", [None])[0]
+
+ if not target:
+ self._send_json(400, {"error": "target query param required"})
+ return
+
+ with neighbor_lock:
+ status = neighbor_status.get(target, {"state": "idle", "message": ""})
+
+ self._send_json(200, status)
+
def _handle_clear(self):
"""Move all walk data to walks/archive/ and reset state."""
global latest_json