Add LLDP neighbor device polling (Cisco C3850 support)

Follow the LLDP breadcrumb back to the connected router/switch:
- New cisco-parse.py: standalone parser for Cisco SNMP walk data
  with interface matching, subinterface/SVI discovery, BDI/BVI
  correlation, and optics extraction
- New /api/neighbor-walk and /api/neighbor-data endpoints
- "Poll Neighbor" button in LLDP topology cards
- Connected Neighbor Devices card showing interface status,
  counters, SVIs, and subinterface mappings
- Platform-aware: handles IOS-XE (SVIs) and IOS-XR (subinterfaces)
- Tested against lab C3850-04 (172.16.50.4) — 4,288 OIDs in 1.1s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sam 2026-03-06 10:48:44 -07:00
parent bcb179e7e4
commit c64a80810f
4 changed files with 1230 additions and 0 deletions

View File

@ -27,5 +27,11 @@ SNMP_WALK_MODE=targeted
# The Traffic Policies card will be empty when disabled. # The Traffic Policies card will be empty when disabled.
SNMP_WALK_POLICIES=true 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 ──
SERVER_PORT=5525 SERVER_PORT=5525

View File

@ -407,6 +407,11 @@ body {{
0%, 100% {{ opacity: 1; }} 0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.4; }} 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 {{ .walk-progress {{
height: 3px; height: 3px;
background: var(--border-color); background: var(--border-color);
@ -620,6 +625,46 @@ body {{
border-left: 1px dashed #3a3f4b; border-left: 1px dashed #3a3f4b;
margin: 0 0.25rem; 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 */ /* Location Map */
#sec-map .card-dark {{ #sec-map .card-dark {{
height: 100%; height: 100%;
@ -681,6 +726,9 @@ body {{
<!-- 3. LLDP TOPOLOGY --> <!-- 3. LLDP TOPOLOGY -->
<div id="sec-lldp"></div> <div id="sec-lldp"></div>
<!-- 3b. CONNECTED NEIGHBOR DATA -->
<div id="sec-neighbor"></div>
<!-- 4. INTERFACES TABLE --> <!-- 4. INTERFACES TABLE -->
<div id="sec-interfaces"></div> <div id="sec-interfaces"></div>
@ -1835,6 +1883,10 @@ function renderLldp() {{
nbr.capsEnabled === '4' ? '<span style="color:var(--cyan)">Router</span>' : nbr.capsEnabled === '4' ? '<span style="color:var(--cyan)">Router</span>' :
'Cap=' + (nbr.capsEnabled||'?')}} 'Cap=' + (nbr.capsEnabled||'?')}}
</div> </div>
${{nbr.mgmtIPv4 && !(nbr.remSysDesc || '').match(/AMN-|AMO-/) ?
`<button class="btn-poll-neighbor" onclick="pollNeighbor('${{nbr.mgmtIPv4}}','${{nbr.remPortId||''}}','${{(nbr.remSysName||'').split('.')[0]}}')">
<i class="bi bi-router"></i> Poll Neighbor
</button>` : ''}}
</div>`; </div>`;
}} }}
@ -1913,6 +1965,216 @@ function renderLldp() {{
</div>`; </div>`;
}} }}
// 3b. Connected Neighbor
function pollNeighbor(target, remPortId, remSysName) {{
const btn = event.currentTarget;
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> 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 = '<i class="bi bi-x-circle"></i> Unreachable';
btn.style.borderColor = 'var(--red)';
btn.style.color = 'var(--red)';
setTimeout(() => {{
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-router"></i> 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 = '<i class="bi bi-arrow-repeat spin"></i> 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 = '<i class="bi bi-check-circle"></i> Done';
btn.style.borderColor = 'var(--green)';
btn.style.color = 'var(--green)';
}});
}} else if (status.state === 'error') {{
clearInterval(pollInterval);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Error';
btn.style.borderColor = 'var(--red)';
btn.style.color = 'var(--red)';
btn.title = status.message || 'Walk failed';
}} else {{
btn.innerHTML = `<i class="bi bi-arrow-repeat spin"></i> ${{status.message || 'Walking...'}}`;
}}
}});
}}, 2000);
}});
}})
.catch(err => {{
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> 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 = `
<div class="nbr-intf-card" style="grid-column: span 2;">
<div class="intf-name">${{esc(qi.ifDescr || qi.remPortId || '?')}}</div>
${{qi.ifAlias ? `<div class="intf-alias">${{esc(qi.ifAlias)}}</div>` : ''}}
<div class="nbr-detail"><span class="nbr-lbl">Status</span> <span class="nbr-status-badge ${{statusClass}}">${{statusText}}</span></div>
<div class="nbr-detail"><span class="nbr-lbl">Speed</span> ${{formatSpeed(qi.ifHighSpeed || qi.ifSpeed)}}</div>
${{qi.ifMtu ? `<div class="nbr-detail"><span class="nbr-lbl">MTU</span> ${{qi.ifMtu}}</div>` : ''}}
<div class="nbr-detail"><span class="nbr-lbl">In</span> ${{formatOctets(qi.ifHCInOctets || qi.ifInOctets)}}</div>
<div class="nbr-detail"><span class="nbr-lbl">Out</span> ${{formatOctets(qi.ifHCOutOctets || qi.ifOutOctets)}}</div>
${{(qi.ifInErrors && qi.ifInErrors !== '0') || (qi.ifOutErrors && qi.ifOutErrors !== '0') ?
`<div class="nbr-detail"><span class="nbr-lbl">Errors</span> <span style="color:var(--red)">In: ${{qi.ifInErrors}} / Out: ${{qi.ifOutErrors}}</span></div>` : ''}}
</div>`;
// Optics card (if available)
if (optics.txPower || optics.rxPower) {{
intfHtml += `
<div class="nbr-intf-card">
<div class="intf-name"><i class="bi bi-broadcast"></i> Optics</div>
${{optics.txPower ? `<div class="nbr-detail"><span class="nbr-lbl">Tx Power</span> ${{optics.txPower}} dBm</div>` : ''}}
${{optics.rxPower ? `<div class="nbr-detail"><span class="nbr-lbl">Rx Power</span> ${{optics.rxPower}} dBm</div>` : ''}}
${{optics.temperature ? `<div class="nbr-detail"><span class="nbr-lbl">Temp</span> ${{optics.temperature}} C</div>` : ''}}
</div>`;
}}
// 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 += `<tr>
<td style="font-family:'JetBrains Mono',monospace">${{esc(s.ifDescr || s.ifName || '?')}}</td>
<td>${{s.vlanId || '?'}}</td>
<td><span class="nbr-status-badge ${{sUp ? 'up' : 'down'}}">${{sUp ? 'Up' : 'Down'}}</span></td>
<td>${{esc(s.ifAlias || '')}}</td>
<td style="font-family:'JetBrains Mono',monospace">${{s.bvi_ifDescr ? esc(s.bvi_ifDescr) : ''}}</td>
<td>${{s.bvi_ifDescr ? `<span class="nbr-status-badge ${{s.bvi_ifOperStatus === '1' ? 'up' : 'down'}}">${{s.bvi_ifOperStatus === '1' ? 'Up' : 'Down'}}</span>` : ''}}</td>
</tr>`;
}}
subsHtml = `
<table class="nbr-sub-table">
<thead><tr><th>Subinterface</th><th>VLAN</th><th>Status</th><th>Description</th><th>BVI/BDI</th><th>L3 Status</th></tr></thead>
<tbody>${{subRows}}</tbody>
</table>`;
}}
// 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 += `<tr>
<td>Vlan${{vk}}</td>
<td><span class="nbr-status-badge ${{vUp ? 'up' : 'down'}}">${{vUp ? 'Up' : 'Down'}}</span></td>
<td>${{esc(v.ifAlias || '')}}</td>
<td style="font-family:'JetBrains Mono',monospace">${{esc(v.ifDescr || '')}}</td>
</tr>`;
}}
vlansHtml = `
<div style="margin-top:0.5rem;font-size:0.75rem;font-weight:600;color:var(--text-muted)">Switch Virtual Interfaces (SVIs)</div>
<table class="nbr-sub-table">
<thead><tr><th>SVI</th><th>Status</th><th>Description</th><th>ifDescr</th></tr></thead>
<tbody>${{vlanRows}}</tbody>
</table>`;
}}
cardsHtml += `
<div class="neighbor-card">
<div class="neighbor-header">
<i class="bi bi-router" style="color:var(--accent)"></i>
${{esc(shortName)}} <span style="color:var(--text-muted);font-size:0.75rem">(${{ip}})</span>
<span class="nbr-platform">${{esc(sys.platform || sys.osType || '')}}</span>
</div>
<div class="neighbor-intf">
${{intfHtml}}
</div>
${{subsHtml}}
${{vlansHtml}}
</div>`;
}}
container.innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-router"></i> Connected Neighbor Devices<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
${{cardsHtml}}
</div>
</div>`;
initCollapsible();
}}
// 7. Coverage Matrix // 7. Coverage Matrix
function renderCoverage() {{ function renderCoverage() {{
// Analyze each section for populated vs empty fields // Analyze each section for populated vs empty fields
@ -2130,6 +2392,7 @@ renderHeader();
renderMap(); renderMap();
renderPanel(); renderPanel();
renderLldp(); renderLldp();
renderNeighbor();
renderInterfaces(); renderInterfaces();
renderSfp(); renderSfp();
renderAlarms(); renderAlarms();

734
cisco-parse.py Normal file
View File

@ -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 <walk_file.txt> <remPortId> [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 <walk_file> <remPortId> [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()

View File

@ -30,6 +30,18 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
WALKS_DIR = SCRIPT_DIR / "walks" 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) ──────────── # ── OID subtrees for targeted walk (mirrors snmp-walk.sh) ────────────
TARGETED_OIDS = [ TARGETED_OIDS = [
(".1.3.6.1.2.1.1", "System"), (".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": if ENV.get("SNMP_WALK_POLICIES", "true").lower() == "true":
TARGETED_OIDS.append((".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB")) 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 state (shared across threads) ───────────────────────────────
walk_lock = threading.Lock() walk_lock = threading.Lock()
@ -98,6 +114,11 @@ walk_version = 0
# Path to latest monitoring JSON (set after successful walk) # Path to latest monitoring JSON (set after successful walk)
latest_json = None 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): def set_status(state, message="", progress=0, **extra):
global walk_version global walk_version
@ -232,6 +253,123 @@ def run_walk(target: str, mode: str, policies: bool = True):
set_status("error", message=str(e)[:300]) 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 ────────────────────────────────────── # ── Find latest monitoring JSON ──────────────────────────────────────
def find_latest_json() -> Path | None: def find_latest_json() -> Path | None:
@ -269,6 +407,10 @@ class NIDHandler(BaseHTTPRequestHandler):
self._serve_viewer() self._serve_viewer()
elif self.path == "/api/status": elif self.path == "/api/status":
self._serve_sse() 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: else:
self._send(404, "text/plain", b"Not found") self._send(404, "text/plain", b"Not found")
@ -285,6 +427,20 @@ class NIDHandler(BaseHTTPRequestHandler):
else: else:
data = {} 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) html = build_html(data)
self._send(200, "text/html; charset=utf-8", html.encode()) self._send(200, "text/html; charset=utf-8", html.encode())
@ -321,6 +477,8 @@ class NIDHandler(BaseHTTPRequestHandler):
self._handle_ping() self._handle_ping()
elif self.path == "/api/clear": elif self.path == "/api/clear":
self._handle_clear() self._handle_clear()
elif self.path == "/api/neighbor-walk":
self._handle_neighbor_walk()
else: else:
self._send(404, "text/plain", b"Not found") self._send(404, "text/plain", b"Not found")
@ -375,6 +533,75 @@ class NIDHandler(BaseHTTPRequestHandler):
thread.start() thread.start()
self._send_json(200, {"status": "started", "target": target, "mode": mode}) 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): def _handle_clear(self):
"""Move all walk data to walks/archive/ and reset state.""" """Move all walk data to walks/archive/ and reset state."""
global latest_json global latest_json