Two-phase focused neighbor walk and fix status/optics bugs

- Restructure neighbor walk into Phase 1 (discovery: ifDescr + ifName +
  ifStackTable) and Phase 2 (targeted snmpget for matched interfaces only).
  Reduces NCS 5500 walk from ~150k OIDs to ~20k discovery + ~600 targeted.
- Rename cisco-parse.py to cisco_parse.py for Python import compatibility.
- Add parse_walk_text() for in-process parsing without file I/O.
- Fix interface status showing DOWN/ADMIN DOWN: use isUp() instead of
  hardcoded === '1' checks, add -Oe flag to snmpget for numeric enums.
- Fix optics showing raw sensor values: apply entSensorPrecision scaling
  (e.g., -95122 with precision 4 → -9.5122 dBm).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sam 2026-03-09 10:58:34 -07:00
parent 9b98e260d1
commit c285810c68
3 changed files with 303 additions and 110 deletions

View File

@ -2056,8 +2056,8 @@ function renderNeighbor() {{
const optics = ndata.optics || {{}}; const optics = ndata.optics || {{}};
const shortName = (sys.sysName || ip).split('.')[0]; const shortName = (sys.sysName || ip).split('.')[0];
const adminUp = qi.ifAdminStatus === '1'; const adminUp = isUp(qi.ifAdminStatus);
const operUp = qi.ifOperStatus === '1'; const operUp = isUp(qi.ifOperStatus);
const statusClass = !adminUp ? 'admin-down' : operUp ? 'up' : 'down'; const statusClass = !adminUp ? 'admin-down' : operUp ? 'up' : 'down';
const statusText = !adminUp ? 'Admin Down' : operUp ? 'Up' : 'Down'; const statusText = !adminUp ? 'Admin Down' : operUp ? 'Up' : 'Down';
@ -2110,14 +2110,14 @@ function renderNeighbor() {{
let subRows = ''; let subRows = '';
for (const sk of subKeys) {{ for (const sk of subKeys) {{
const s = subs[sk]; const s = subs[sk];
const sUp = s.ifOperStatus === '1'; const sUp = isUp(s.ifOperStatus);
subRows += `<tr> subRows += `<tr>
<td style="font-family:'JetBrains Mono',monospace">${{esc(s.ifDescr || s.ifName || '?')}}</td> <td style="font-family:'JetBrains Mono',monospace">${{esc(s.ifDescr || s.ifName || '?')}}</td>
<td>${{s.vlanId || '?'}}</td> <td>${{s.vlanId || '?'}}</td>
<td><span class="nbr-status-badge ${{sUp ? 'up' : 'down'}}">${{sUp ? 'Up' : 'Down'}}</span></td> <td><span class="nbr-status-badge ${{sUp ? 'up' : 'down'}}">${{sUp ? 'Up' : 'Down'}}</span></td>
<td>${{esc(s.ifAlias || '')}}</td> <td>${{esc(s.ifAlias || '')}}</td>
<td style="font-family:'JetBrains Mono',monospace">${{s.bvi_ifDescr ? esc(s.bvi_ifDescr) : ''}}</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> <td>${{s.bvi_ifDescr ? `<span class="nbr-status-badge ${{isUp(s.bvi_ifOperStatus) ? 'up' : 'down'}}">${{isUp(s.bvi_ifOperStatus) ? 'Up' : 'Down'}}</span>` : ''}}</td>
</tr>`; </tr>`;
}} }}
subsHtml = ` subsHtml = `
@ -2134,7 +2134,7 @@ function renderNeighbor() {{
let vlanRows = ''; let vlanRows = '';
for (const vk of vlanKeys.sort((a,b) => parseInt(a) - parseInt(b))) {{ for (const vk of vlanKeys.sort((a,b) => parseInt(a) - parseInt(b))) {{
const v = vlans[vk]; const v = vlans[vk];
const vUp = v.ifOperStatus === '1'; const vUp = isUp(v.ifOperStatus);
vlanRows += `<tr> vlanRows += `<tr>
<td>Vlan${{vk}}</td> <td>Vlan${{vk}}</td>
<td><span class="nbr-status-badge ${{vUp ? 'up' : 'down'}}">${{vUp ? 'Up' : 'Down'}}</span></td> <td><span class="nbr-status-badge ${{vUp ? 'up' : 'down'}}">${{vUp ? 'Up' : 'Down'}}</span></td>

View File

@ -103,31 +103,22 @@ OID_CISCO_SENSOR_PREFIX = ".1.3.6.1.4.1.9.9.91.1.1.1.1"
# Walk file parser # Walk file parser
# ──────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────
def parse_walk_file(walk_file): def _parse_lines(lines):
"""Parse an snmpbulkwalk -On -OQ output file into {oid: value} dict. """Parse snmpbulkwalk/snmpget -On -OQ output lines 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
Handles multi-line quoted values (e.g., Cisco sysDescr).
String values have surrounding quotes stripped. String values have surrounding quotes stripped.
""" """
walk_path = Path(walk_file)
oid_data = {} oid_data = {}
with walk_path.open("r", errors="replace") as fh:
pending_oid = None pending_oid = None
pending_val = None pending_val = None
for raw_line in fh: for raw_line in lines:
line = raw_line.rstrip("\n\r") line = raw_line.rstrip("\n\r")
# Handle multi-line values (e.g., Cisco sysDescr spans multiple lines)
if pending_oid is not None: if pending_oid is not None:
# Continuation of a multi-line quoted value
pending_val += " " + line.strip() pending_val += " " + line.strip()
if '"' in line: if '"' in line:
# Closing quote found — finalize
val = pending_val.strip() val = pending_val.strip()
if val.startswith('"'): if val.startswith('"'):
val = val[1:] val = val[1:]
@ -142,7 +133,6 @@ def parse_walk_file(walk_file):
if not line or line.startswith("#"): if not line or line.startswith("#"):
continue continue
# Split on first ' = '
parts = line.split(" = ", 1) parts = line.split(" = ", 1)
if len(parts) != 2: if len(parts) != 2:
continue continue
@ -150,19 +140,16 @@ def parse_walk_file(walk_file):
oid = parts[0].strip() oid = parts[0].strip()
value = parts[1].strip() value = parts[1].strip()
# Check for opening quote without closing (multi-line value)
if value.startswith('"') and not value.endswith('"'): if value.startswith('"') and not value.endswith('"'):
pending_oid = oid pending_oid = oid
pending_val = value pending_val = value
continue continue
# Strip surrounding quotes
if len(value) >= 2 and value[0] == '"' and value[-1] == '"': if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
value = value[1:-1] value = value[1:-1]
oid_data[oid] = value oid_data[oid] = value
# Handle any trailing pending value
if pending_oid is not None: if pending_oid is not None:
val = pending_val.strip().strip('"') val = pending_val.strip().strip('"')
oid_data[pending_oid] = val oid_data[pending_oid] = val
@ -170,6 +157,21 @@ def parse_walk_file(walk_file):
return oid_data return oid_data
def parse_walk_file(walk_file):
"""Parse an snmpbulkwalk -On -OQ output file into {oid: value} dict."""
walk_path = Path(walk_file)
with walk_path.open("r", errors="replace") as fh:
return _parse_lines(fh)
def parse_walk_text(text):
"""Parse snmpbulkwalk/snmpget -On -OQ output from a string.
Useful for in-process parsing without writing to a file first.
"""
return _parse_lines(text.splitlines())
# ──────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────
# Helpers # Helpers
# ──────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────
@ -539,6 +541,14 @@ def get_optics_info(oid_data, parent_ifindex, parent_descr):
# entSensorType: .1.3.6.1.4.1.9.9.91.1.1.1.1.1.{idx} # 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." sensor_type_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.1."
# entSensorPrecision: .1.3.6.1.4.1.9.9.91.1.1.1.1.3.{idx}
# Number of decimal places to apply to entSensorValue
sensor_precision_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.3."
# entSensorScale: .1.3.6.1.4.1.9.9.91.1.1.1.1.2.{idx}
# Scale factor (1=yocto..9=units..17=exa) — 9 means no scaling
sensor_scale_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.2."
# Find entity indices that match the interface # Find entity indices that match the interface
short_name = _shorten_name(parent_descr) short_name = _shorten_name(parent_descr)
matching_ent_indices = [] matching_ent_indices = []
@ -556,6 +566,25 @@ def get_optics_info(oid_data, parent_ifindex, parent_descr):
_dbg(f"Found {len(matching_ent_indices)} entity entries for {parent_descr}") _dbg(f"Found {len(matching_ent_indices)} entity entries for {parent_descr}")
def _scale_sensor_value(raw_val, ent_idx):
"""Apply entSensorPrecision to scale a raw sensor value."""
try:
val = float(raw_val)
except (ValueError, TypeError):
return raw_val
precision = _get(oid_data, f"{sensor_precision_prefix}{ent_idx}")
try:
prec = int(precision)
except (ValueError, TypeError):
prec = 0
if prec > 0:
val = val / (10 ** prec)
# Round to avoid floating point noise
return str(round(val, prec if prec > 0 else 1))
# For matching entities, look up sensor readings # For matching entities, look up sensor readings
for ent_idx in matching_ent_indices: for ent_idx in matching_ent_indices:
sensor_val = _get(oid_data, f"{sensor_value_prefix}{ent_idx}") sensor_val = _get(oid_data, f"{sensor_value_prefix}{ent_idx}")
@ -566,20 +595,23 @@ def get_optics_info(oid_data, parent_ifindex, parent_descr):
sensor_type = _get(oid_data, f"{sensor_type_prefix}{ent_idx}") sensor_type = _get(oid_data, f"{sensor_type_prefix}{ent_idx}")
ent_descr = _get(oid_data, f"{ent_descr_prefix}{ent_idx}").lower() ent_descr = _get(oid_data, f"{ent_descr_prefix}{ent_idx}").lower()
# Scale the raw value using entSensorPrecision
scaled = _scale_sensor_value(sensor_val, ent_idx)
# entSensorType: 8 = celsius, 14 = dBm # entSensorType: 8 = celsius, 14 = dBm
# Also check description text for classification # Also check description text for classification
if sensor_type == "8" or "temperature" in ent_descr or "temp" in ent_descr: if sensor_type == "8" or "temperature" in ent_descr or "temp" in ent_descr:
result["temperature"] = sensor_val result["temperature"] = scaled
elif "transmit" in ent_descr or "tx" in ent_descr: elif "transmit" in ent_descr or "tx" in ent_descr:
result["txPower"] = sensor_val result["txPower"] = scaled
elif "receive" in ent_descr or "rx" in ent_descr: elif "receive" in ent_descr or "rx" in ent_descr:
result["rxPower"] = sensor_val result["rxPower"] = scaled
elif sensor_type == "14": elif sensor_type == "14":
# dBm but unclassified — assign to first empty power slot # dBm but unclassified — assign to first empty power slot
if result["txPower"] is None: if result["txPower"] is None:
result["txPower"] = sensor_val result["txPower"] = scaled
elif result["rxPower"] is None: elif result["rxPower"] is None:
result["rxPower"] = sensor_val result["rxPower"] = scaled
return result return result

View File

@ -30,14 +30,40 @@ 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 ──────────────────── # ── Phase 1: Discovery OIDs (lightweight, column-specific walks) ──────
NEIGHBOR_TARGETED_OIDS = [ # On an NCS 5500 with 10k+ interfaces, walking full ifTable/ifXTable would
(".1.3.6.1.2.1.1", "System"), # return ~150k OIDs. Instead, walk only the columns needed to identify the
(".1.3.6.1.2.1.2.2.1", "ifTable"), # target interface and its children. ~2 OIDs per interface for discovery.
(".1.3.6.1.2.1.31.1.1.1", "ifXTable"), NEIGHBOR_DISCOVERY_OIDS = [
(".1.3.6.1.2.1.31.1.2", "ifStackTable"), (".1.3.6.1.2.1.1", "System"), # ~8 OIDs
(".1.3.6.1.2.1.17.7.1.4.3.1", "dot1qVlanStatic"), (".1.3.6.1.2.1.2.2.1.2", "ifDescr"), # 1 column: ifDescr
(".1.3.6.1.4.1.9.9.46.1.3.1", "vtpVlanTable"), (".1.3.6.1.2.1.31.1.1.1.1", "ifName"), # 1 column: ifName
(".1.3.6.1.2.1.31.1.2", "ifStackTable"), # parent-child relationships
]
# ── Phase 2: Per-interface OID suffixes for targeted snmpget ─────────
# After matching the target ifIndex + children, we GET only these OIDs
# for each relevant interface. ~15 OIDs per interface instead of ~150k total.
NEIGHBOR_INTERFACE_OID_BASES = [
".1.3.6.1.2.1.2.2.1.3", # ifType
".1.3.6.1.2.1.2.2.1.4", # ifMtu
".1.3.6.1.2.1.2.2.1.5", # ifSpeed
".1.3.6.1.2.1.2.2.1.7", # ifAdminStatus
".1.3.6.1.2.1.2.2.1.8", # ifOperStatus
".1.3.6.1.2.1.2.2.1.10", # ifInOctets
".1.3.6.1.2.1.2.2.1.13", # ifInDiscards
".1.3.6.1.2.1.2.2.1.14", # ifInErrors
".1.3.6.1.2.1.2.2.1.16", # ifOutOctets
".1.3.6.1.2.1.2.2.1.19", # ifOutDiscards
".1.3.6.1.2.1.2.2.1.20", # ifOutErrors
".1.3.6.1.2.1.31.1.1.1.6", # ifHCInOctets
".1.3.6.1.2.1.31.1.1.1.10", # ifHCOutOctets
".1.3.6.1.2.1.31.1.1.1.15", # ifHighSpeed
".1.3.6.1.2.1.31.1.1.1.18", # ifAlias
]
# ── Extra subtrees to walk for IOS-XE VLAN/optics (Phase 2 optional) ─
NEIGHBOR_EXTRA_OIDS = [
(".1.3.6.1.2.1.47.1.1.1", "entPhysicalTable"), (".1.3.6.1.2.1.47.1.1.1", "entPhysicalTable"),
(".1.3.6.1.4.1.9.9.91.1.1.1", "ciscoEntitySensor"), (".1.3.6.1.4.1.9.9.91.1.1.1", "ciscoEntitySensor"),
] ]
@ -263,8 +289,80 @@ def build_neighbor_snmp_auth() -> list:
return ["-v", NEIGHBOR_SNMP_VERSION, "-c", NEIGHBOR_SNMP_COMMUNITY] return ["-v", NEIGHBOR_SNMP_VERSION, "-c", NEIGHBOR_SNMP_COMMUNITY]
def _walk_subtrees_parallel(walk_cmd, auth, target, oid_list, status_prefix=""):
"""Walk a list of (oid, label) subtrees in parallel. Returns combined text."""
total = len(oid_list)
completed = [0]
results_map = {}
def walk_one(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"{status_prefix}({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_one, i, oid, label)
for i, (oid, label) in enumerate(oid_list)
]
for fut in as_completed(futures):
idx, output = fut.result()
if output.strip():
results_map[idx] = output
return "\n".join(results_map[i] for i in sorted(results_map))
def _snmpget_batch(walk_cmd, auth, target, oid_list):
"""Run snmpget for a batch of specific OIDs. Returns raw output text.
Uses snmpget (not bulkwalk) since we're requesting exact OIDs.
Falls back to individual gets if batch fails.
"""
if not oid_list:
return ""
# snmpget can handle multiple OIDs in one call (much faster than individual)
# Split into chunks of 30 to avoid command-line length limits
all_output = []
for i in range(0, len(oid_list), 30):
chunk = oid_list[i:i + 30]
try:
res = subprocess.run(
["snmpget", "-On", "-OQ", "-Oe"] + auth + [target] + chunk,
capture_output=True, text=True, timeout=30,
)
if res.stdout.strip():
all_output.append(res.stdout)
except subprocess.TimeoutExpired:
pass
return "\n".join(all_output)
def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""): def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
"""Execute a targeted SNMP walk against an LLDP neighbor device.""" """Execute a two-phase focused SNMP walk against an LLDP neighbor device.
Phase 1 (Discovery): Walk System + ifDescr + ifName + ifStackTable
Identify target ifIndex and child subinterfaces
Phase 2 (Targeted): snmpget ~15 OIDs per matched interface only
Full interface facts for only the relevant interfaces
On an NCS 5500 with 10k interfaces, this reduces from ~150k OIDs
to ~20k discovery + ~600 targeted GETs.
"""
ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$") ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
if not ip_re.match(target): if not ip_re.match(target):
with neighbor_lock: with neighbor_lock:
@ -272,7 +370,7 @@ def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
return return
with neighbor_lock: with neighbor_lock:
neighbor_status[target] = {"state": "walking", "message": "Starting neighbor walk..."} neighbor_status[target] = {"state": "walking", "message": "Phase 1: Discovering interfaces..."}
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
safe_ip = target.replace(".", "-") safe_ip = target.replace(".", "-")
@ -284,40 +382,103 @@ def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
t_start = time.time() t_start = time.time()
try: try:
# Walk neighbor subtrees in parallel # ── Phase 1: Discovery walk ──────────────────────────────────
total = len(NEIGHBOR_TARGETED_OIDS) discovery_output = _walk_subtrees_parallel(
completed = [0] walk_cmd, auth, target, NEIGHBOR_DISCOVERY_OIDS,
results_map = {} status_prefix="Phase 1: Discovery "
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
if not discovery_output.strip():
with neighbor_lock:
neighbor_status[target] = {
"state": "error",
"message": "Discovery walk returned no data — check credentials",
}
return
# Parse discovery data in-process to find target interface
sys.path.insert(0, str(SCRIPT_DIR))
from cisco_parse import parse_walk_text, build_interface_index, match_rem_port_id, \
discover_subinterfaces_stack, discover_subinterfaces_pattern
discovery_oids = parse_walk_text(discovery_output)
interfaces = build_interface_index(discovery_oids)
discovery_count = len(discovery_oids)
with neighbor_lock: with neighbor_lock:
neighbor_status[target] = { neighbor_status[target] = {
"state": "walking", "state": "walking",
"message": f"Walking subtrees ({completed[0]}/{total})", "message": f"Phase 1 done: {discovery_count:,} OIDs, {len(interfaces)} interfaces. Matching...",
} }
return idx, res.stdout
except subprocess.TimeoutExpired:
completed[0] += 1
return idx, ""
with ThreadPoolExecutor(max_workers=4) as pool: matched_ifindex = match_rem_port_id(interfaces, rem_port_id)
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)] if matched_ifindex is None:
walk_file.write_text("\n".join(output_lines)) # Write what we have and let cisco-parse produce a best-effort result
walk_file.write_text(discovery_output)
else:
# Find child interfaces (subinterfaces)
parent_descr = interfaces.get(matched_ifindex, {}).get("ifDescr", "")
child_indices = set()
# ifStackTable children
child_indices |= discover_subinterfaces_stack(discovery_oids, matched_ifindex)
# Pattern-based children (ifDescr matching)
if parent_descr:
pattern_children = discover_subinterfaces_pattern(discovery_oids, parent_descr)
child_indices |= set(pattern_children.keys())
# Also look for BDI/BVI interfaces that correlate with subinterfaces
bvi_indices = set()
for child_idx in child_indices:
child_descr = interfaces.get(child_idx, {}).get("ifDescr", "")
vlan_match = re.search(r"\.(\d+)$", child_descr)
if vlan_match:
vlan_id = vlan_match.group(1)
# Search ifDescr for BDI{N} or BVI{N}
for ifidx, info in interfaces.items():
d = info.get("ifDescr", "")
if d == f"BDI{vlan_id}" or d == f"BVI{vlan_id}":
bvi_indices.add(ifidx)
# Also find Vlan{N} SVIs (IOS-XE)
vlan_indices = set()
for ifidx, info in interfaces.items():
d = info.get("ifDescr", "")
if re.match(r"^Vlan\d+$", d):
vlan_indices.add(ifidx)
all_target_indices = {matched_ifindex} | child_indices | bvi_indices | vlan_indices
with neighbor_lock:
neighbor_status[target] = {
"state": "walking",
"message": f"Phase 2: Getting details for {len(all_target_indices)} interfaces...",
}
# ── Phase 2: Targeted snmpget ────────────────────────────
target_oids = []
for ifidx in all_target_indices:
for base_oid in NEIGHBOR_INTERFACE_OID_BASES:
target_oids.append(f"{base_oid}.{ifidx}")
phase2_output = _snmpget_batch(walk_cmd, auth, target, target_oids)
# Also walk optics/entity subtrees (small on most devices)
extra_output = _walk_subtrees_parallel(
walk_cmd, auth, target, NEIGHBOR_EXTRA_OIDS,
status_prefix="Phase 2: Optics/Entity "
)
# Combine all phases into one walk file
combined = discovery_output
if phase2_output.strip():
combined += "\n" + phase2_output
if extra_output.strip():
combined += "\n" + extra_output
walk_file.write_text(combined)
line_count = sum(1 for _ in walk_file.open()) line_count = sum(1 for _ in walk_file.open())
elapsed = round(time.time() - t_start, 1) elapsed = round(time.time() - t_start, 1)
@ -330,12 +491,12 @@ def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
} }
return return
# Parse with cisco-parse.py # ── Parse combined data with cisco-parse.py ──────────────────
with neighbor_lock: with neighbor_lock:
neighbor_status[target] = {"state": "parsing", "message": "Parsing neighbor data..."} neighbor_status[target] = {"state": "parsing", "message": "Parsing neighbor data..."}
parse_result = subprocess.run( parse_result = subprocess.run(
[sys.executable, str(SCRIPT_DIR / "cisco-parse.py"), [sys.executable, str(SCRIPT_DIR / "cisco_parse.py"),
str(walk_file), rem_port_id], str(walk_file), rem_port_id],
capture_output=True, text=True, timeout=60, capture_output=True, text=True, timeout=60,
) )
@ -361,7 +522,7 @@ def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
latest_neighbor[target] = neighbor_json latest_neighbor[target] = neighbor_json
neighbor_status[target] = { neighbor_status[target] = {
"state": "complete", "state": "complete",
"message": f"Done — {line_count:,} lines in {elapsed}s", "message": f"Done — {line_count:,} OIDs ({discovery_count:,} discovery + {line_count - discovery_count:,} targeted) in {elapsed}s",
"json_path": str(neighbor_json), "json_path": str(neighbor_json),
} }