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 = ` + + + ${{subRows}} +
SubinterfaceVLANStatusDescriptionBVI/BDIL3 Status
`; + }} + + // 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)
+ + + ${{vlanRows}} +
SVIStatusDescriptionifDescr
`; + }} + + cardsHtml += ` +
+
+ + ${{esc(shortName)}} (${{ip}}) + ${{esc(sys.platform || sys.osType || '')}} +
+
+ ${{intfHtml}} +
+ ${{subsHtml}} + ${{vlansHtml}} +
`; + }} + + container.innerHTML = ` +
+
Connected Neighbor Devices
+
+ ${{cardsHtml}} +
+
`; + 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