nid-snmp/build_nid_viewer.py
sam c285810c68 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>
2026-03-09 10:58:34 -07:00

2439 lines
95 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Accedian NID SNMP Data Visualizer
Reads a *_monitoring.json file produced by snmp-parse.py and generates
a self-contained HTML page showing the device model, ports, SFPs,
alarms, config, and SNMP data coverage.
Usage:
python3 build_nid_viewer.py [monitoring_json]
"""
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
WALKS_DIR = SCRIPT_DIR / "walks"
DEFAULT_INPUT = WALKS_DIR / "10-13-60-102_2026-02-27_11-23-07_walk_monitoring.json"
def safe_json(obj, **kwargs):
"""JSON-encode and escape sequences unsafe inside <script> tags."""
s = json.dumps(obj, **kwargs)
return s.replace("<", "\\u003c")
def format_uptime(seconds_str):
"""Convert uptimeSeconds string to human-readable."""
try:
total = int(seconds_str)
except (ValueError, TypeError):
return seconds_str or "?"
days, rem = divmod(total, 86400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
parts = []
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
parts.append(f"{secs}s")
return " ".join(parts)
def build_html(data: dict) -> str:
"""Build the complete self-contained HTML page."""
data_json = safe_json(data, indent=None)
device = data.get("device", {})
page_title = device.get("identifier", "Accedian NID")
return f'''<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NID Viewer &mdash; {page_title}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root {{
--bg-dark: #0f1117;
--bg-card: #1a1d24;
--bg-card2: #22262f;
--text-main: #e2e8f0;
--text-muted: #a0b4c8;
--border-color: #2d3340;
--accent: #0d6efd;
--green: #22c55e;
--amber: #f59e0b;
--red: #dc3545;
--crit: #dc3545;
--major: #fd7e14;
--minor: #ffc107;
--info-sev: #17a2b8;
--cyan: #06b6d4;
}}
body {{
background: var(--bg-dark);
color: var(--text-main);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 14px;
}}
.card-dark {{
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 1rem;
}}
.card-dark .card-header {{
background: var(--bg-card2);
border-bottom: 1px solid var(--border-color);
padding: 0.6rem 1rem;
font-weight: 600;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 8px 8px 0 0;
}}
.card-dark .card-header.collapsible {{
cursor: pointer;
user-select: none;
}}
.card-dark .card-header.collapsible:hover {{
background: #282c36;
}}
.card-dark .card-header .collapse-chevron {{
margin-left: auto;
transition: transform 0.2s;
font-size: 0.8rem;
color: var(--text-muted);
}}
.card-dark .card-header.collapsed .collapse-chevron {{
transform: rotate(-90deg);
}}
.card-dark .card-body {{ padding: 1rem; }}
.card-dark .card-body.collapsed {{ display: none; }}
.table-dark-custom {{
--bs-table-bg: transparent;
--bs-table-color: var(--text-main);
--bs-table-border-color: var(--border-color);
font-size: 0.85rem;
margin-bottom: 0;
}}
.table-dark-custom th {{
background: var(--bg-card2);
font-weight: 600;
white-space: nowrap;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted);
}}
.table-dark-custom td {{ vertical-align: middle; }}
.mono {{ font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-size: 0.82rem; }}
.badge-sev-3 {{ background: var(--crit); }}
.badge-sev-2 {{ background: var(--major); }}
.badge-sev-1 {{ background: var(--minor); color: #000; }}
.badge-sev-0 {{ background: var(--info-sev); }}
.status-up {{ color: var(--green); font-weight: 600; }}
.status-down {{ color: var(--red); font-weight: 600; }}
.status-na {{ color: #555; }}
.kv-grid {{
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 1rem;
font-size: 0.85rem;
}}
.kv-grid dt {{ color: var(--text-muted); white-space: nowrap; font-weight: 500; }}
.kv-grid dd {{ margin: 0; }}
/* Front panel */
.front-panel {{
background: #16181f;
border: 2px solid #3a3f4b;
border-radius: 6px;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
position: relative;
}}
.panel-label {{
position: absolute;
top: -10px;
left: 12px;
background: var(--bg-card);
padding: 0 6px;
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.sfp-slot {{
width: 56px;
height: 44px;
border-radius: 4px;
border: 2px solid #3a3f4b;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
position: relative;
}}
.sfp-slot:hover {{ border-color: var(--accent); transform: translateY(-1px); }}
.sfp-slot.present-link {{ background: rgba(34,197,94,0.15); border-color: var(--green); color: var(--green); }}
.sfp-slot.present-nolink {{ background: rgba(245,158,11,0.15); border-color: var(--amber); color: var(--amber); }}
.sfp-slot.empty {{ background: #1a1d24; border-color: #2d3340; color: #555; }}
.sfp-slot.selected {{ box-shadow: 0 0 0 2px var(--accent); }}
.sfp-slot .slot-label {{ font-size: 0.6rem; color: var(--text-muted); }}
.sfp-slot-group {{
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}}
.port-label {{
font-size: 0.55rem;
color: var(--text-muted);
text-align: center;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
min-height: 1.8em;
}}
.mgmt-port {{
width: 40px;
height: 44px;
border-radius: 4px;
border: 2px solid #3a3f4b;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 0.55rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}}
.mgmt-port.link-up {{ background: rgba(34,197,94,0.15); border-color: var(--green); color: var(--green); }}
.mgmt-port.link-down {{ background: #1a1d24; border-color: #2d3340; color: #555; }}
.divider {{ width: 1px; height: 44px; background: var(--border-color); margin: 0 0.3rem; }}
.pwr-led {{
width: 12px; height: 12px; border-radius: 50%;
display: inline-block;
margin-right: 4px;
}}
.pwr-led.ok {{ background: var(--green); box-shadow: 0 0 6px rgba(34,197,94,0.5); }}
.pwr-led.fail {{ background: var(--red); box-shadow: 0 0 6px rgba(220,53,69,0.5); }}
.pwr-block {{ display: flex; flex-direction: column; gap: 4px; font-size: 0.7rem; }}
.temp-block {{
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.75rem;
min-width: 80px;
}}
.temp-bar-track {{
height: 6px;
background: #2d3340;
border-radius: 3px;
overflow: hidden;
position: relative;
}}
.temp-bar-fill {{
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}}
/* CPU gauge */
.cpu-bar {{
display: inline-block;
width: 40px;
height: 8px;
background: #2d3340;
border-radius: 4px;
overflow: hidden;
vertical-align: middle;
margin-left: 4px;
}}
.cpu-bar-fill {{
height: 100%;
border-radius: 4px;
background: var(--accent);
}}
/* Coverage */
.cov-bar-track {{
height: 10px;
background: #2d3340;
border-radius: 5px;
overflow: hidden;
min-width: 120px;
}}
.cov-bar-fill {{
height: 100%;
border-radius: 5px;
background: var(--accent);
}}
.oid-bar-track {{
height: 14px;
background: #2d3340;
border-radius: 3px;
overflow: hidden;
}}
.oid-bar-fill {{
height: 100%;
background: var(--accent);
border-radius: 3px;
}}
.gap-callout {{
background: rgba(220,53,69,0.08);
border-left: 3px solid var(--red);
padding: 0.5rem 0.75rem;
border-radius: 0 4px 4px 0;
font-size: 0.82rem;
margin-bottom: 0.5rem;
}}
.gap-callout.warn {{
background: rgba(245,158,11,0.08);
border-left-color: var(--amber);
}}
/* Walk control card */
.walk-controls {{
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}}
.walk-controls label {{
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
}}
.walk-input {{
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-main);
padding: 0.35rem 0.6rem;
font-size: 0.85rem;
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
width: 160px;
}}
.walk-input:focus {{
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(13,110,253,0.25);
}}
.walk-select {{
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-main);
padding: 0.35rem 0.6rem;
font-size: 0.85rem;
}}
.walk-select:focus {{ outline: none; border-color: var(--accent); }}
.walk-toggle {{
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
user-select: none;
}}
.walk-toggle input[type="checkbox"] {{
accent-color: var(--accent);
cursor: pointer;
}}
.walk-btn {{
background: var(--accent);
border: none;
border-radius: 4px;
color: #fff;
padding: 0.35rem 1rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}}
.walk-btn:hover {{ background: #0b5ed7; }}
.walk-btn:disabled {{
background: #2d3340;
color: #555;
cursor: not-allowed;
}}
.walk-btn-clear {{
background: #3a3a4a;
}}
.walk-btn-clear:hover {{ background: var(--red); }}
.walk-status {{
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
margin-top: 0.5rem;
}}
.walk-dot {{
width: 8px; height: 8px; border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}}
.walk-dot.idle {{ background: #555; }}
.walk-dot.running {{ background: var(--amber); animation: walk-pulse 1s ease-in-out infinite; }}
.walk-dot.complete {{ background: var(--green); }}
.walk-dot.error {{ background: var(--red); }}
@keyframes walk-pulse {{
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);
border-radius: 2px;
overflow: hidden;
margin-top: -1px;
}}
.walk-progress-fill {{
height: 100%;
background: var(--accent);
border-radius: 2px;
width: 0%;
transition: width 0.3s ease;
}}
/* SFP detail card */
.sfp-detail {{ display: none; }}
.sfp-detail.active {{ display: block; }}
.unavailable {{
opacity: 0.4;
position: relative;
}}
.unavailable::after {{
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(100,100,100,0.08) 4px,
rgba(100,100,100,0.08) 8px
);
border-radius: 4px;
pointer-events: none;
}}
/* Mismatch highlight */
.mismatch {{ background: rgba(253,126,20,0.12) !important; }}
/* Scrollable table wrapper */
.tbl-scroll {{
max-height: 400px;
overflow-y: auto;
}}
.tbl-scroll::-webkit-scrollbar {{ width: 6px; }}
.tbl-scroll::-webkit-scrollbar-track {{ background: var(--bg-card); }}
.tbl-scroll::-webkit-scrollbar-thumb {{ background: var(--border-color); border-radius: 3px; }}
/* LLDP topology diagram (redesigned) */
.lldp-panel {{
background: #16181f;
border: 2px solid #3a3f4b;
border-radius: 6px;
padding: 1.2rem 1.5rem;
position: relative;
}}
.lldp-panel .panel-label {{
position: absolute;
top: -10px;
left: 12px;
background: var(--bg-card);
padding: 0 6px;
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}}
/* ── LLDP vertical columns ── */
.lldp-columns {{
display: flex;
gap: 1rem;
align-items: stretch;
}}
.lldp-col {{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}}
.lldp-col.idle {{ opacity: 0.4; }}
.lldp-col-header {{
width: 56px;
height: 44px;
border-radius: 4px;
border: 2px solid #3a3f4b;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 600;
flex-shrink: 0;
}}
.lldp-col-header.present-link {{
background: rgba(34,197,94,0.15);
border-color: var(--green);
color: var(--green);
}}
.lldp-col-header.present-nolink {{
background: rgba(245,158,11,0.15);
border-color: var(--amber);
color: var(--amber);
}}
.lldp-col-header.empty {{
background: #1a1d24;
border-color: #2d3340;
color: #555;
}}
.lldp-col-header .slot-label {{
font-size: 0.6rem;
color: var(--text-muted);
}}
.lldp-col-port-label {{
font-size: 0.6rem;
color: var(--text-muted);
text-align: center;
font-family: 'JetBrains Mono', monospace;
margin: 0.3rem 0 0.15rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}}
.lldp-col-line {{
width: 3px;
height: 36px;
border-radius: 2px;
margin: 0.15rem 0;
position: relative;
}}
.lldp-col-line.up {{
background: var(--green);
box-shadow: 0 0 8px rgba(34,197,94,0.3);
}}
.lldp-col-line.down {{
background: var(--amber);
box-shadow: 0 0 8px rgba(245,158,11,0.3);
}}
.lldp-col-line.idle {{
background: #2d3340;
height: 24px;
opacity: 0.5;
}}
.lldp-col-line::before,
.lldp-col-line::after {{
content: '';
position: absolute;
left: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
transform: translateX(-50%);
}}
.lldp-col-line.up::before,
.lldp-col-line.up::after {{ background: var(--green); }}
.lldp-col-line.down::before,
.lldp-col-line.down::after {{ background: var(--amber); }}
.lldp-col-line::before {{ top: -4px; }}
.lldp-col-line::after {{ bottom: -4px; }}
.lldp-col-line.idle::before,
.lldp-col-line.idle::after {{ display: none; }}
.lldp-col-remote {{
background: #1e2128;
border: 1px solid #3a3f4b;
border-radius: 4px;
padding: 0.5rem 0.75rem;
width: 100%;
margin-top: 0.15rem;
flex: 1;
}}
.lldp-col-remote .remote-hostname {{
font-size: 0.85rem;
font-weight: 700;
color: var(--text-main);
word-break: break-all;
}}
.lldp-col-remote .remote-model {{
font-size: 0.72rem;
color: var(--cyan);
margin-bottom: 0.3rem;
}}
.lldp-col-remote .remote-detail {{
font-size: 0.7rem;
color: var(--text-muted);
margin: 0.1rem 0;
font-family: 'JetBrains Mono', monospace;
}}
.lldp-col-remote .remote-detail .rlabel {{
color: #555;
display: inline-block;
min-width: 32px;
}}
.lldp-col-remote .remote-mgmt {{
font-size: 0.75rem;
color: var(--green);
font-weight: 600;
margin-top: 0.3rem;
font-family: 'JetBrains Mono', monospace;
}}
.lldp-col-idle-label {{
font-size: 0.7rem;
color: #555;
font-style: italic;
margin-top: 0.3rem;
}}
.lldp-col-divider {{
width: 1px;
align-self: stretch;
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%;
display: flex;
flex-direction: column;
}}
#sec-map .card-body {{
flex: 1;
display: flex;
flex-direction: column;
}}
#nid-map {{
flex: 1;
min-height: 400px;
border-radius: 4px;
background: var(--bg-dark);
}}
.leaflet-container {{
background: var(--bg-dark) !important;
}}
.map-coords {{
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.4rem;
font-family: 'JetBrains Mono', monospace;
}}
#sec-header .card-dark {{
height: 100%;
}}
#sec-sfp > .card-dark,
#sec-alarms > .card-dark,
#sec-filters > .card-dark,
#sec-regulators > .card-dark {{
flex: 1;
display: flex;
flex-direction: column;
}}
#sec-alarms > .card-dark > .card-body,
#sec-regulators > .card-dark > .card-body {{
flex: 1;
}}
</style>
</head>
<body>
<div class="container-fluid py-3" style="max-width:1800px">
<!-- ═══════════════ 0. WALK CONTROL ═══════════════ -->
<div id="sec-walk"></div>
<!-- ═══════════════ 1. DEVICE HEADER + MAP ═══════════════ -->
<div style="display:flex;gap:1rem;align-items:stretch">
<div id="sec-header" style="flex:1;min-width:0"></div>
<div id="sec-map" style="flex:1;min-width:0"></div>
</div>
<!-- ═══════════════ 2. FRONT PANEL ═══════════════ -->
<div id="sec-panel"></div>
<!-- ═══════════════ 3. LLDP TOPOLOGY ═══════════════ -->
<div id="sec-lldp"></div>
<!-- ═══════════════ 3b. CONNECTED NEIGHBOR DATA ═══════════════ -->
<div id="sec-neighbor"></div>
<!-- ═══════════════ 4. INTERFACES TABLE ═══════════════ -->
<div id="sec-interfaces"></div>
<!-- ═══════════════ 56. SFP + ALARMS (side by side) ═══════════════ -->
<div style="display:flex;gap:1rem;align-items:stretch">
<div id="sec-sfp" style="flex:1;min-width:0;display:flex;flex-direction:column"></div>
<div id="sec-alarms" style="flex:1;min-width:0;display:flex;flex-direction:column"></div>
</div>
<!-- ═══════════════ 7. TRAFFIC POLICIES ═══════════════ -->
<div id="sec-policies"></div>
<!-- ═══════════════ 89. L2 FILTERS + REGULATORS (side by side) ═══════════════ -->
<div style="display:flex;gap:1rem;align-items:stretch">
<div id="sec-filters" style="flex:1;min-width:0;display:flex;flex-direction:column"></div>
<div id="sec-regulators" style="flex:1;min-width:0;display:flex;flex-direction:column"></div>
</div>
<!-- ═══════════════ 10. COVERAGE MATRIX ═══════════════ -->
<div id="sec-coverage"></div>
<!-- ═══════════════ 11. PORT CONFIG vs STATUS ═══════════════ -->
<div id="sec-portcmp"></div>
</div><!-- container -->
<script>
const DATA = {data_json};
// ── Helpers ──────────────────────────────────────────
function esc(s) {{
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}}
function formatBytes(n) {{
n = parseInt(n);
if (isNaN(n) || n === 0) return '0 B';
const u = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(n)/Math.log(1024));
return (n/Math.pow(1024,i)).toFixed(i?1:0) + ' ' + u[i];
}}
function formatUptime(sec) {{
sec = parseInt(sec);
if (isNaN(sec)) return '?';
const d = Math.floor(sec/86400), h = Math.floor((sec%86400)/3600),
m = Math.floor((sec%3600)/60), s = sec%60;
let p = [];
if (d) p.push(d+'d');
if (h) p.push(h+'h');
if (m) p.push(m+'m');
p.push(s+'s');
return p.join(' ');
}}
// SNMP TruthValue: some agents return '1'/'2', others 'true'/'false'
function isTrue(v) {{ return v === '1' || v === 'true' || v === true; }}
// Derive data ports and management port from connectors dynamically
function getPortLists() {{
const connectors = DATA.connectors || {{}};
const keys = Object.keys(connectors).sort((a,b) => parseInt(a) - parseInt(b));
const dataPorts = [];
let mgmtPort = null;
for (const k of keys) {{
const c = connectors[k];
if (c.name && c.name.toLowerCase() === 'management') {{
mgmtPort = k;
}} else {{
dataPorts.push(k);
}}
}}
return {{ dataPorts, mgmtPort }};
}}
function sevLabel(s) {{
return {{'0':'INFO','1':'MINOR','2':'MAJOR','3':'CRITICAL'}}[s] || s;
}}
function sevClass(s) {{
return 'badge-sev-' + (s||'0');
}}
function cpuBar(pct) {{
pct = parseInt(pct)||0;
const c = pct > 80 ? 'var(--red)' : pct > 50 ? 'var(--amber)' : 'var(--accent)';
return `<span class="cpu-bar"><span class="cpu-bar-fill" style="width:${{pct}}%;background:${{c}}"></span></span> ${{pct}}%`;
}}
function tempColor(cur, high, crit) {{
cur = parseInt(cur)||0; high = parseInt(high)||85; crit = parseInt(crit)||90;
if (cur >= crit) return 'var(--red)';
if (cur >= high) return 'var(--amber)';
return 'var(--green)';
}}
function parseDateAndTime(hex) {{
// SNMP DateAndTime: 11 bytes hex-encoded like "07 B5 01 18 12 07 19 00 2D 07 00"
if (!hex || hex.startsWith('00 00 01 01 00 00')) return '';
const p = hex.split(' ').map(h => parseInt(h,16));
if (p.length < 8) return hex;
const yr = (p[0]<<8)|p[1], mo=p[2], dy=p[3], hr=p[4], mn=p[5], sc=p[6];
return `${{yr}}-${{String(mo).padStart(2,'0')}}-${{String(dy).padStart(2,'0')}} ${{String(hr).padStart(2,'0')}}:${{String(mn).padStart(2,'0')}}:${{String(sc).padStart(2,'0')}}`;
}}
function isPopulated(v) {{
if (v == null || v === '') return false;
if (v === '0' || v === '-inf dBm' || v === '0.0') return false;
if (/^0+$/.test(v.replace(/\\s/g,''))) return false;
if (/^(00\\s)+00?$/.test(v.trim())) return false;
return true;
}}
// SNMP status helpers — handles both text ("up") and numeric ("1") values
function isUp(v) {{ return v === 'up' || v === '1'; }}
function isDown(v) {{ return v === 'down' || v === '2'; }}
function statusLabel(v) {{
const map = {{'1':'up','2':'down','3':'testing','4':'unknown','5':'dormant','6':'notPresent','7':'lowerLayerDown'}};
return map[v] || v || '?';
}}
// SFF-8024 connector type lookup (Table 4-3)
function sffConnectorType(v) {{
const map = {{
'1':'SC', '2':'FC Style 1', '3':'FC Style 2', '5':'MT-RJ', '6':'MU',
'7':'LC', '8':'LC', '9':'MTP/MPO 1x12', '10':'MTP/MPO 2x16',
'11':'SG', '12':'Optical pigtail', '13':'MTP/MPO 1x16',
'32':'HSSDC II', '33':'Copper pigtail', '34':'RJ45', '35':'No separable connector',
'36':'MXC 2x16', '37':'CS optical', '38':'SN optical', '39':'MTP/MPO 2x12',
'40':'MTP/MPO 1x16'
}};
if (!v || v === '0') return 'Unknown';
return map[v] || (parseInt(v) >= 128 ? 'Vendor specific' : `Code ${{v}}`);
}}
// ── Card collapse toggle ─────────────────────────────
function toggleCard(header) {{
header.classList.toggle('collapsed');
const body = header.nextElementSibling;
if (body && body.classList.contains('card-body')) {{
body.classList.toggle('collapsed');
}}
}}
// Attach listeners after all cards are rendered
function initCollapsible() {{
document.querySelectorAll('.card-header.collapsible').forEach(h => {{
h.addEventListener('click', () => toggleCard(h));
}});
}}
// ── 0. Walk Control ──────────────────────────────────
function renderWalkControl() {{
// Try to extract a default IP from existing data
const ipAddrs = DATA.ip_addresses || {{}};
const ipList = Object.values(ipAddrs).filter(ip => ip.address && ip.address !== '127.0.0.1');
const defaultIp = ipList.length > 0 ? ipList[0].address : '';
document.getElementById('sec-walk').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible">
<i class="bi bi-broadcast"></i> SNMP Walk Control
<span class="collapse-chevron"><i class="bi bi-chevron-down"></i></span>
</div>
<div class="walk-progress"><div class="walk-progress-fill" id="walk-progress-fill"></div></div>
<div class="card-body">
<div class="walk-controls">
<label>Target IP</label>
<input type="text" class="walk-input" id="walk-target"
value="${{esc(defaultIp)}}" placeholder="10.0.0.1"
spellcheck="false" autocomplete="off">
<label>Mode</label>
<select class="walk-select" id="walk-mode">
<option value="targeted">Targeted</option>
<option value="full">Full</option>
</select>
<label class="walk-toggle" title="ACD-POLICY-MIB is ~73% of all OIDs — disable for faster walks">
<input type="checkbox" id="walk-policies" checked>
Policies
</label>
<button class="walk-btn" id="walk-btn" onclick="startWalk()">
<i class="bi bi-play-fill"></i> Walk
</button>
<button class="walk-btn walk-btn-clear" id="clear-btn" onclick="clearData()">
<i class="bi bi-trash3"></i> Clear
</button>
</div>
<div class="walk-status" id="walk-status">
<span class="walk-dot idle"></span>
<span>Idle</span>
</div>
</div>
</div>`;
}}
let walkEventSource = null;
function startWalk() {{
const target = document.getElementById('walk-target').value.trim();
const mode = document.getElementById('walk-mode').value;
const policies = document.getElementById('walk-policies').checked;
const btn = document.getElementById('walk-btn');
if (!target) {{
updateWalkStatus('error', 'Enter a target IP address');
return;
}}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Pinging...';
updateWalkStatus('running', 'Checking reachability...');
// Step 1: Ping check
fetch('/api/ping', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ target }})
}})
.then(r => r.json())
.then(ping => {{
if (!ping.reachable) {{
updateWalkStatus('error', 'NID is DOWN. Verify Local Power and Router Interface Status.');
resetWalkBtn();
return;
}}
updateWalkStatus('complete', 'NID Management is UP');
// Step 2: Proceed with walk after brief pause to show UP status
setTimeout(() => {{
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Walking...';
updateWalkStatus('running', 'Starting walk...');
// Close any previous SSE connection
if (walkEventSource) walkEventSource.close();
fetch('/api/walk', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ target, mode, policies }})
}})
.then(r => r.json())
.then(resp => {{
if (resp.error) {{
updateWalkStatus('error', resp.error);
resetWalkBtn();
return;
}}
// Open SSE for status updates
walkEventSource = new EventSource('/api/status');
walkEventSource.onmessage = (e) => {{
const s = JSON.parse(e.data);
const pct = s.progress || 0;
document.getElementById('walk-progress-fill').style.width = pct + '%';
if (s.state === 'complete') {{
updateWalkStatus('complete', s.message);
walkEventSource.close();
walkEventSource = null;
setTimeout(() => window.location.reload(), 800);
}} else if (s.state === 'error') {{
updateWalkStatus('error', s.message);
walkEventSource.close();
walkEventSource = null;
resetWalkBtn();
}} else {{
updateWalkStatus('running', s.message);
}}
}};
walkEventSource.onerror = () => {{
walkEventSource.close();
walkEventSource = null;
}};
}})
.catch(err => {{
updateWalkStatus('error', 'Failed to connect: ' + err.message);
resetWalkBtn();
}});
}}, 600);
}})
.catch(err => {{
updateWalkStatus('error', 'Ping check failed: ' + err.message);
resetWalkBtn();
}});
}}
function updateWalkStatus(state, message) {{
const el = document.getElementById('walk-status');
if (!el) return;
const dotClass = state === 'running' ? 'running' : state === 'complete' ? 'complete' : state === 'error' ? 'error' : 'idle';
const color = state === 'error' ? 'var(--red)' : state === 'complete' ? 'var(--green)' : '';
el.innerHTML = `<span class="walk-dot ${{dotClass}}"></span><span style="color:${{color}}">${{esc(message)}}</span>`;
}}
function resetWalkBtn() {{
const btn = document.getElementById('walk-btn');
if (btn) {{
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-fill"></i> Walk';
}}
document.getElementById('walk-progress-fill').style.width = '0%';
}}
function clearData() {{
if (!confirm('Clear all walk data and reload?')) return;
fetch('/api/clear', {{ method: 'POST' }})
.then(r => r.json())
.then(() => window.location.reload())
.catch(err => updateWalkStatus('error', 'Clear failed: ' + err.message));
}}
// ── 1. Device Header ─────────────────────────────────
function renderHeader() {{
const d = DATA.device || {{}};
const alarmStatus = DATA.alarm_status || {{}};
let activeCount = 0, sevCounts = {{0:0,1:0,2:0,3:0}};
const alarmCfg = DATA.alarm_config || {{}};
// Build number→config lookup
const cfgByNum = {{}};
for (const [k,v] of Object.entries(alarmCfg)) cfgByNum[v.number] = v;
for (const [k,a] of Object.entries(alarmStatus)) {{
if (isTrue(a.active)) {{
activeCount++;
const cfg = cfgByNum[a.number];
if (cfg) sevCounts[cfg.severity] = (sevCounts[cfg.severity]||0) + 1;
}}
}}
const hasAlarmStatus = Object.keys(alarmStatus).length > 0;
let alarmBadge = '';
if (hasAlarmStatus) {{
if (activeCount > 0) {{
const parts = [];
if (sevCounts[3]) parts.push(`<span class="badge badge-sev-3">${{sevCounts[3]}} CRIT</span>`);
if (sevCounts[2]) parts.push(`<span class="badge badge-sev-2">${{sevCounts[2]}} MAJ</span>`);
if (sevCounts[1]) parts.push(`<span class="badge badge-sev-1">${{sevCounts[1]}} MIN</span>`);
if (sevCounts[0]) parts.push(`<span class="badge badge-sev-0">${{sevCounts[0]}} INFO</span>`);
alarmBadge = `<span class="badge bg-danger">${{activeCount}} Active Alarms</span> ${{parts.join(' ')}}`;
}} else {{
alarmBadge = '<span class="badge bg-success">No Active Alarms</span>';
}}
}} else if (Object.keys(alarmCfg).length > 0) {{
alarmBadge = `<span class="badge bg-secondary">${{Object.keys(alarmCfg).length}} Alarm Defs</span>`;
}}
// Extract management IP from ip_addresses data
const ipAddrs = DATA.ip_addresses || {{}};
const ifaces = DATA.interfaces || {{}};
let mgmtIpHtml = '';
const ipList = Object.values(ipAddrs).filter(ip => ip.address && ip.address !== '127.0.0.1');
if (ipList.length > 0) {{
const ipItems = ipList.map(ip => {{
const ifEntry = ifaces[ip.ifIndex] || {{}};
// Only show interface name if it's a real (non-synthetic) interface
const ifName = ifEntry.synthetic !== '1' ? (ifEntry.ifName || ifEntry.ifDescr || '') : '';
const suffix = ifName ? ` <span style="color:var(--text-muted);font-size:0.75rem">on ${{esc(ifName)}}</span>` : '';
return `<span class="mono" style="color:var(--green)">${{esc(ip.address)}}/${{esc(ip.prefixLength)}}</span>${{suffix}}`;
}});
mgmtIpHtml = ipItems.join('<br>');
}} else {{
mgmtIpHtml = '<span style="color:#555">Not available</span>';
}}
document.getElementById('sec-header').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible">
<i class="bi bi-router"></i>
${{esc(d.commercialName || d.sysDescr || 'Accedian NID')}}
<span style="font-weight:400;font-size:0.82rem;color:var(--text-muted);margin-left:auto">
SNMP Walk Visualization
</span>
<i class="bi bi-chevron-down collapse-chevron" style="margin-left:0.5rem"></i>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<dl class="kv-grid">
<dt>Hostname</dt><dd class="mono">${{esc(d.sysName)}}</dd>
<dt>Identifier</dt><dd class="mono">${{esc(d.identifier)}}</dd>
<dt>Mgmt IP</dt><dd>${{mgmtIpHtml}}</dd>
<dt>Serial</dt><dd class="mono">${{esc(d.serialNumber)}}</dd>
<dt>Firmware</dt><dd class="mono">${{esc(d.firmwareVersion)}}</dd>
<dt>Hardware</dt><dd class="mono">${{esc(d.hardwareVersion)}}</dd>
<dt>MAC</dt><dd class="mono">${{esc(d.macBaseAddr)}}</dd>
<dt>Options</dt><dd>${{esc(d.hardwareOptions)}}</dd>
<dt>Location</dt><dd>${{esc(d.sysLocation)}}</dd>
<dt>Contact</dt><dd>${{esc(d.sysContact)}}</dd>
<dt>Uptime</dt><dd class="mono">${{formatUptime(d.uptimeSeconds)}}</dd>
</dl>
</div>
<div class="col-md-3">
<h6 style="font-size:0.8rem;color:var(--text-muted);margin-bottom:0.5rem">CPU UTILIZATION</h6>
<div style="font-size:0.82rem">
<div>Current: ${{cpuBar(d.cpuUsageCurrent)}}</div>
<div>15s avg: ${{cpuBar(d.cpuUsageAvg15s)}}</div>
<div>30s avg: ${{cpuBar(d.cpuUsageAvg30s)}}</div>
<div>60s avg: ${{cpuBar(d.cpuUsageAvg60s)}}</div>
<div>15m avg: ${{cpuBar(d.cpuUsageAvg900s)}}</div>
</div>
</div>
<div class="col-md-3 text-end">
<div style="margin-bottom:0.5rem">${{alarmBadge}}</div>
<div style="font-size:0.78rem;color:var(--text-muted)">
${{hasAlarmStatus ? Object.keys(alarmStatus).length + ' alarm status entries' : Object.keys(alarmCfg).length + ' alarm definitions'}}<br>
${{Object.keys(DATA.interfaces||{{}}).length}} interfaces<br>
${{Object.keys(DATA.connectors||{{}}).length}} connectors
</div>
</div>
</div>
</div>
</div>`;
}}
// ── 1b. Location Map ────────────────────────────────
function renderMap() {{
const d = DATA.device || {{}};
const loc = (d.sysLocation || '').trim();
const m = loc.match(/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/);
if (!m) return; // no valid coordinates — skip map
const lat = parseFloat(m[1]);
const lon = parseFloat(m[2]);
const hostname = d.sysName || d.identifier || 'NID';
document.getElementById('sec-map').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-geo-alt"></i> Location Map<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<div id="nid-map"></div>
<div class="map-coords"><i class="bi bi-crosshair"></i> ${{lat.toFixed(6)}}, ${{lon.toFixed(6)}}</div>
</div>
</div>`;
const map = L.map('nid-map').setView([lat, lon], 15);
L.tileLayer('https://{{s}}.basemaps.cartocdn.com/dark_all/{{z}}/{{x}}/{{y}}{{r}}.png', {{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19
}}).addTo(map);
L.marker([lat, lon]).addTo(map)
.bindPopup(`<b>${{esc(hostname)}}</b><br>${{lat.toFixed(6)}}, ${{lon.toFixed(6)}}`)
.openPopup();
// Leaflet needs a resize nudge when rendered in a hidden/collapsed container
setTimeout(() => map.invalidateSize(), 200);
}}
// ── 2. Front Panel ───────────────────────────────────
function renderPanel() {{
const connectors = DATA.connectors || {{}};
const sfpInfo = DATA.sfp_info || {{}};
const portStatus = DATA.port_status || {{}};
const ifaces = DATA.interfaces || {{}};
const portCfg = DATA.port_config || {{}};
const pwr = DATA.power_supplies || {{}};
const temps = DATA.temperature_sensors || {{}};
// Connector type: "14" = SFP, "2" = RJ45/copper
function connType(connIdx) {{
const c = connectors[connIdx];
return (c && c.type === '14') ? 'sfp' : 'rj45';
}}
// Determine slot state based on connector type and interface/SFP status
function slotState(connIdx) {{
const iface = ifaces[connIdx];
if (connType(connIdx) === 'sfp') {{
const sfp = sfpInfo[connIdx];
const present = sfp && isTrue(sfp.present);
if (!present) return 'empty';
if (iface && isUp(iface.ifOperStatus)) return 'present-link';
return 'present-nolink';
}} else {{
// RJ45/copper — no SFP presence; use link status only
if (iface && isUp(iface.ifOperStatus)) return 'present-link';
if (iface && isDown(iface.ifOperStatus)) return 'present-nolink';
return 'empty';
}}
}}
function sfpLabel(connIdx) {{
const sfp = sfpInfo[connIdx];
if (!sfp) return '';
if (!isTrue(sfp.present)) return 'EMPTY';
const pn = sfp.vendorPn || '';
if (pn.length > 8) return pn.substring(0,8);
return pn || sfp.vendor || '';
}}
// Get port label (ifName or port_config name) and alias
function portInfo(connIdx) {{
const iface = ifaces[connIdx] || {{}};
const cfg = portCfg[connIdx] || {{}};
const label = iface.ifName || cfg.name || '';
const alias = (cfg.alias && cfg.alias.trim()) || (iface.ifAlias && iface.ifAlias.trim()) || '';
return {{ label, alias }};
}}
// Build port slots dynamically from connectors
const {{ dataPorts, mgmtPort }} = getPortLists();
let slots = '';
for (const idx of dataPorts) {{
const conn = connectors[idx] || {{}};
const isSfp = connType(idx) === 'sfp';
const state = slotState(idx);
const pi = portInfo(idx);
const slotName = conn.name || (isSfp ? `SFP-${{idx}}` : `RJ45-${{idx}}`);
let icon, detail;
if (isSfp) {{
icon = state === 'empty' ? '<i class="bi bi-dash"></i>' :
state === 'present-link' ? '<i class="bi bi-arrow-left-right"></i>' :
'<i class="bi bi-plug"></i>';
detail = sfpLabel(idx);
}} else {{
icon = '<i class="bi bi-ethernet"></i>';
detail = state === 'present-link' ? 'UP' : state === 'present-nolink' ? 'DOWN' : '';
}}
const labelParts = [];
if (pi.label) labelParts.push(esc(pi.label));
if (pi.alias) labelParts.push(`<span style="color:var(--cyan)">${{esc(pi.alias)}}</span>`);
const belowLabel = labelParts.length
? `<div class="port-label" title="${{esc(pi.label + (pi.alias ? ' / ' + pi.alias : ''))}}">${{labelParts.join('<br>')}}</div>`
: '';
slots += `<div class="sfp-slot-group">
<div class="sfp-slot ${{state}}" data-sfp="${{idx}}" onclick="selectSfp(${{idx}})">
${{icon}}<span class="slot-label">${{esc(slotName)}}</span>
<span style="font-size:0.5rem;max-width:52px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${{esc(detail)}}</span>
</div>
${{belowLabel}}
</div>`;
}}
// Management port
const mgmtIf = mgmtPort ? ifaces[mgmtPort] : null;
const mgmtUp = mgmtIf && isUp(mgmtIf.ifOperStatus);
const mgmtSlot = `<div class="mgmt-port ${{mgmtUp ? 'link-up' : 'link-down'}}">
<i class="bi bi-ethernet"></i><span style="font-size:0.55rem">MGMT</span>
<span style="font-size:0.5rem">${{mgmtUp ? 'UP' : 'DOWN'}}</span>
</div>`;
// Power feeds
let pwrHtml = '<div class="pwr-block">';
for (const [k,p] of Object.entries(pwr)) {{
const ok = isTrue(p.present);
pwrHtml += `<div><span class="pwr-led ${{ok?'ok':'fail'}}"></span>${{esc(p.name)}} ${{ok?'OK':'ABSENT'}}</div>`;
}}
pwrHtml += '</div>';
// Temperature
let tempHtml = '';
for (const [k,t] of Object.entries(temps)) {{
const cur = parseInt(t.currentTemp)||0;
const crit = parseInt(t.criticalThreshold)||90;
const pct = Math.min(100, Math.round(cur/crit*100));
const col = tempColor(t.currentTemp, t.highThreshold, t.criticalThreshold);
tempHtml += `<div class="temp-block">
<span style="color:${{col}};font-weight:600">${{cur}}&deg;C</span>
<span style="font-size:0.6rem;color:var(--text-muted)">${{esc(t.label)}} (warn:${{t.highThreshold}} crit:${{t.criticalThreshold}})</span>
<div class="temp-bar-track"><div class="temp-bar-fill" style="width:${{pct}}%;background:${{col}}"></div></div>
</div>`;
}}
document.getElementById('sec-panel').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-cpu"></i> Front Panel<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<div class="front-panel">
<span class="panel-label">${{esc((DATA.device||{{}}).sysDescr || 'NID')}}</span>
${{slots}}
<div class="divider"></div>
${{mgmtSlot}}
<div class="divider"></div>
${{pwrHtml}}
<div class="divider"></div>
${{tempHtml}}
</div>
<div style="font-size:0.7rem;color:var(--text-muted);margin-top:0.5rem">
<span style="display:inline-block;width:10px;height:10px;background:rgba(34,197,94,0.3);border:1px solid var(--green);border-radius:2px;margin-right:2px"></span> Link Up &nbsp;
<span style="display:inline-block;width:10px;height:10px;background:rgba(245,158,11,0.3);border:1px solid var(--amber);border-radius:2px;margin-right:2px"></span> No Link &nbsp;
<span style="display:inline-block;width:10px;height:10px;background:#1a1d24;border:1px solid #2d3340;border-radius:2px;margin-right:2px"></span> Empty / Down
</div>
</div>
</div>`;
}}
// ── 3. Interfaces Table ──────────────────────────────
function renderInterfaces() {{
const ifaces = DATA.interfaces || {{}};
const portCfg = DATA.port_config || {{}};
let rows = '';
const sortedKeys = Object.keys(ifaces).sort((a,b) => parseInt(a)-parseInt(b));
for (const idx of sortedKeys) {{
const iface = ifaces[idx];
const cfg = portCfg[idx] || {{}};
const up = isUp(iface.ifOperStatus);
const statusCls = up ? 'status-up' : 'status-down';
const rowCls = up ? '' : 'style="opacity:0.7"';
const speed = parseInt(iface.ifHighSpeed);
let speedStr = '';
if (speed >= 1000) speedStr = (speed/1000) + ' Gbps';
else if (speed > 0) speedStr = speed + ' Mbps';
else speedStr = '<span class="status-na">--</span>';
rows += `<tr ${{rowCls}}>
<td class="mono">${{idx}}</td>
<td><strong>${{esc(iface.ifDescr)}}</strong></td>
<td class="${{statusCls}}">${{statusLabel(iface.ifAdminStatus)}}</td>
<td class="${{statusCls}}">${{statusLabel(iface.ifOperStatus)}}</td>
<td>${{speedStr}}</td>
<td class="mono">${{esc(iface.ifMtu)}}</td>
<td class="mono" style="font-size:0.75rem">${{esc(iface.ifPhysAddress)}}</td>
<td class="mono">${{formatBytes(iface.ifHCInOctets)}}</td>
<td class="mono">${{formatBytes(iface.ifHCOutOctets)}}</td>
<td>${{parseInt(iface.ifInErrors)||0}}</td>
<td>${{parseInt(iface.ifInDiscards)||0}}</td>
<td>${{parseInt(iface.ifOutErrors)||0}}</td>
</tr>`;
}}
document.getElementById('sec-interfaces').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-ethernet"></i> Interfaces &amp; Traffic<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body" style="padding:0">
<div class="tbl-scroll">
<table class="table table-dark-custom table-sm table-hover">
<thead><tr>
<th>#</th><th>Name</th><th>Admin</th><th>Oper</th><th>Speed</th>
<th>MTU</th><th>MAC</th><th>RX</th><th>TX</th>
<th>In Err</th><th>In Disc</th><th>Out Err</th>
</tr></thead>
<tbody>${{rows}}</tbody>
</table>
</div>
</div>
</div>`;
}}
// ── 4. SFP Cards ─────────────────────────────────────
let selectedSfp = null;
function selectSfp(idx) {{
selectedSfp = idx;
document.querySelectorAll('.sfp-slot').forEach(s => s.classList.remove('selected'));
const el = document.querySelector(`.sfp-slot[data-sfp="${{idx}}"]`);
if (el) el.classList.add('selected');
document.querySelectorAll('.sfp-detail').forEach(d => d.classList.remove('active'));
const detail = document.getElementById('sfp-detail-'+idx);
if (detail) detail.classList.add('active');
}}
function renderSfp() {{
const connectors = DATA.connectors || {{}};
const sfpInfo = DATA.sfp_info || {{}};
const sfpDiag = DATA.sfp_diagnostics || {{}};
const sfpThresh = DATA.sfp_thresholds || {{}};
const {{ dataPorts }} = getPortLists();
let cards = '';
for (const si of dataPorts) {{
const info = sfpInfo[si];
const diag = sfpDiag[si] || {{}};
const thresh = sfpThresh[si] || {{}};
const conn = connectors[si] || {{}};
if (!info || !isTrue(info.present)) {{
cards += `<div class="sfp-detail" id="sfp-detail-${{si}}">
<div class="card-dark" style="border-left:3px solid #555">
<div class="card-body" style="text-align:center;color:#555;padding:2rem">
<i class="bi bi-slash-circle" style="font-size:2rem"></i>
<div>${{esc(conn.name || 'Port ' + si)}} &mdash; Not Present</div>
</div>
</div>
</div>`;
continue;
}}
const ddm = isTrue(info.diagCapable);
const ddmBadge = ddm
? '<span class="badge bg-success">DDM Supported</span>'
: '<span class="badge bg-secondary">DDM Not Supported</span>';
let diagHtml;
if (ddm) {{
diagHtml = `<div class="row g-2 mt-2">
<div class="col-6"><dt>TX Power</dt><dd class="mono">${{esc(diag.txPower_dBm)}}</dd></div>
<div class="col-6"><dt>RX Power</dt><dd class="mono">${{esc(diag.rxPower_dBm)}}</dd></div>
<div class="col-4"><dt>Temp</dt><dd class="mono">${{esc(diag.temperature)}}&deg;C</dd></div>
<div class="col-4"><dt>Vcc</dt><dd class="mono">${{esc(diag.supplyVoltage)}}</dd></div>
<div class="col-4"><dt>LBC</dt><dd class="mono">${{esc(diag.laserBiasCurrent)}} uA</dd></div>
</div>`;
}} else {{
diagHtml = `<div class="unavailable" style="padding:0.75rem;border-radius:4px;margin-top:0.5rem">
<div style="text-align:center;color:#666;font-size:0.82rem">
<i class="bi bi-eye-slash"></i> SFP DDM diagnostics not available via SNMP<br>
<span style="font-size:0.75rem">diagCapable=false &mdash; SNMP agent reports zeros.<br>
NID web UI reads SFP I2C bus directly (bypasses SNMP).</span>
</div>
<div class="row g-2 mt-1" style="opacity:0.3">
<div class="col-6"><dt>TX Power</dt><dd class="mono">${{esc(diag.txPower_dBm || '--')}}</dd></div>
<div class="col-6"><dt>RX Power</dt><dd class="mono">${{esc(diag.rxPower_dBm || '--')}}</dd></div>
<div class="col-4"><dt>Temp</dt><dd class="mono">--</dd></div>
<div class="col-4"><dt>Vcc</dt><dd class="mono">--</dd></div>
<div class="col-4"><dt>LBC</dt><dd class="mono">--</dd></div>
</div>
</div>`;
}}
const wl = info.wavelength && info.wavelength !== '0' ? info.wavelength + ' nm' : 'N/A (copper)';
const mfgDate = [info.mfgYear, String(info.mfgMonth||'').padStart(2,'0'), String(info.mfgDay||'').padStart(2,'0')].join('-');
cards += `<div class="sfp-detail ${{si===dataPorts[0]?'active':''}}" id="sfp-detail-${{si}}">
<div class="card-dark" style="border-left:3px solid var(--accent)">
<div class="card-header">
<i class="bi bi-lightning-charge"></i> ${{esc(conn.name || 'Port ' + si)}}: ${{esc(info.vendor)}} ${{esc(info.vendorPn)}}
<span class="ms-auto">${{ddmBadge}}</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<dl class="kv-grid">
<dt>Vendor</dt><dd class="mono">${{esc(info.vendor)}}</dd>
<dt>Part Number</dt><dd class="mono">${{esc(info.vendorPn)}}</dd>
<dt>Revision</dt><dd class="mono">${{esc(info.vendorRev)}}</dd>
<dt>Serial</dt><dd class="mono">${{esc(info.serialNum)}}</dd>
<dt>Wavelength</dt><dd class="mono">${{wl}}</dd>
<dt>Manufactured</dt><dd class="mono">${{mfgDate}}</dd>
<dt>Connector Type</dt><dd class="mono">${{sffConnectorType(info.connectorType)}}</dd>
</dl>
</div>
<div class="col-md-6">
<h6 style="font-size:0.8rem;color:var(--text-muted)">CAPABILITIES</h6>
<div style="font-size:0.82rem">
<div>DDM Capable: <strong>${{ddm ? 'Yes' : 'No'}}</strong></div>
<div>Internal Cal: <strong>${{isTrue(info.internalCal) ? 'Yes' : 'No'}}</strong></div>
<div>Alarm Capable: <strong>${{isTrue(info.alarmCapable) ? 'Yes' : 'No'}}</strong></div>
<div>SFF-8472 Rev: <strong>${{esc(info.rev8472)}}</strong></div>
<div>ID Type: <strong>${{esc(info.idType)}}</strong></div>
<div>Ext ID: <strong>${{esc(info.extIdType)}}</strong></div>
</div>
<h6 style="font-size:0.8rem;color:var(--text-muted);margin-top:0.75rem">DIAGNOSTICS</h6>
${{diagHtml}}
</div>
</div>
</div>
</div>
</div>`;
}}
document.getElementById('sec-sfp').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-lightning-charge"></i> SFP Transceivers<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<div style="font-size:0.78rem;color:var(--text-muted);margin-bottom:0.5rem">
Click an SFP slot in the front panel above, or select below:
${{dataPorts.map(k => `<button class="btn btn-sm btn-outline-secondary ms-1" onclick="selectSfp(${{k}})">${{esc((connectors[k]||{{}}).name || 'Port '+k)}}</button>`).join('')}}
</div>
${{cards}}
</div>
</div>`;
}}
// ── 5. Alarms ────────────────────────────────────────
function renderAlarms() {{
const alarmCfg = DATA.alarm_config || {{}};
const alarmStatus = DATA.alarm_status || {{}};
const alarmGen = DATA.alarm_general || {{}};
const hasStatus = Object.keys(alarmStatus).length > 0;
// Build number→config lookup
const cfgByNum = {{}};
for (const [k,v] of Object.entries(alarmCfg)) cfgByNum[v.number] = v;
// Collect active alarms (when status table available)
const active = [];
for (const [k,a] of Object.entries(alarmStatus)) {{
if (isTrue(a.active)) {{
const cfg = cfgByNum[a.number] || {{}};
active.push({{ ...a, ...cfg, _statusId: k }});
}}
}}
active.sort((a,b) => parseInt(b.severity||0) - parseInt(a.severity||0));
let rows = '';
let headerCols = '';
const total = hasStatus ? Object.keys(alarmStatus).length : Object.keys(alarmCfg).length;
if (hasStatus) {{
// Full status view — active alarms with live state
headerCols = '<th>Severity</th><th>Condition</th><th>Object</th><th>Description</th><th>Message</th><th>Last Change</th>';
for (const a of active) {{
const dt = parseDateAndTime(a.lastChange);
rows += `<tr>
<td><span class="badge ${{sevClass(a.severity)}}">${{sevLabel(a.severity)}}</span></td>
<td class="mono">${{esc(a.conditionType)}}</td>
<td class="mono">${{esc(a.amoType)}}</td>
<td>${{esc(a.description)}}</td>
<td>${{esc(a.message)}}</td>
<td class="mono" style="font-size:0.75rem">${{dt}}</td>
</tr>`;
}}
}} else {{
// Config-only view — show alarm definitions (device lacks status table)
// Detect which columns the device actually exposes
const hasSeverity = Object.values(alarmCfg).some(c => c.severity);
const hasCondition = Object.values(alarmCfg).some(c => c.conditionType);
headerCols = '<th>#</th><th>Number</th><th>Description</th>';
if (hasSeverity) headerCols += '<th>Severity</th><th>Enabled</th>';
if (hasCondition) headerCols += '<th>Condition</th><th>Object</th>';
const cfgList = Object.values(alarmCfg).sort((a,b) => parseInt(a.id||0) - parseInt(b.id||0));
for (const c of cfgList) {{
rows += `<tr>
<td class="mono">${{esc(c.id)}}</td>
<td class="mono">${{esc(c.number)}}</td>
<td>${{esc(c.description)}}</td>`;
if (hasSeverity) rows += `
<td><span class="badge ${{sevClass(c.severity)}}">${{sevLabel(c.severity)}}</span></td>
<td>${{isTrue(c.enabled) ? '<span style="color:var(--green)">Yes</span>' : '<span style="color:var(--text-muted)">No</span>'}}</td>`;
if (hasCondition) rows += `
<td class="mono">${{esc(c.conditionType)}}</td>
<td class="mono">${{esc(c.amoType)}}</td>`;
rows += '</tr>';
}}
}}
// Severity breakdown (only meaningful with status data)
const sevCounts = {{0:0,1:0,2:0,3:0}};
active.forEach(a => sevCounts[a.severity] = (sevCounts[a.severity]||0)+1);
const badgeHtml = hasStatus
? `<span class="badge bg-danger ms-2">${{active.length}} Active</span>`
: `<span class="badge bg-secondary ms-2">Config Only</span>`;
const sevBar = hasStatus ? `
<div style="padding:0.5rem 1rem;font-size:0.8rem;display:flex;gap:1rem;flex-wrap:wrap">
<span><span class="badge badge-sev-3">&nbsp;</span> Critical: ${{sevCounts[3]}}</span>
<span><span class="badge badge-sev-2">&nbsp;</span> Major: ${{sevCounts[2]}}</span>
<span><span class="badge badge-sev-1">&nbsp;</span> Minor: ${{sevCounts[1]}}</span>
<span><span class="badge badge-sev-0">&nbsp;</span> Info: ${{sevCounts[0]}}</span>
<span class="ms-auto" style="color:var(--text-muted)">
Thresh On: ${{alarmGen.threshOnMs||'?'}}ms | Off: ${{alarmGen.threshOffMs||'?'}}ms |
LED: ${{alarmGen.ledEnabled==='1'?'On':'Off'}} |
Syslog: ${{alarmGen.syslogEnabled==='1'?'On':'Off'}} |
SNMP Trap: ${{alarmGen.snmpEnabled==='1'?'On':'Off'}}
</span>
</div>` : (Object.keys(alarmGen).length > 0 ? `
<div style="padding:0.5rem 1rem;font-size:0.8rem;color:var(--text-muted)">
Thresh On: ${{alarmGen.threshOnMs||'?'}}ms | Off: ${{alarmGen.threshOffMs||'?'}}ms |
LED: ${{alarmGen.ledEnabled==='1'?'On':'Off'}} |
Syslog: ${{alarmGen.syslogEnabled==='1'?'On':'Off'}} |
SNMP Trap: ${{alarmGen.snmpEnabled==='1'?'On':'Off'}}
</div>` : '');
document.getElementById('sec-alarms').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible">
<i class="bi bi-exclamation-triangle"></i> Alarms
${{badgeHtml}}
<span style="font-weight:400;font-size:0.78rem;color:var(--text-muted);margin-left:auto">${{total}} defined</span>
<i class="bi bi-chevron-down collapse-chevron" style="margin-left:0.5rem"></i>
</div>
<div class="card-body" style="padding:0">
${{sevBar}}
<div class="tbl-scroll" style="max-height:350px">
<table class="table table-dark-custom table-sm table-hover">
<thead><tr>${{headerCols}}</tr></thead>
<tbody>${{rows}}</tbody>
</table>
</div>
</div>
</div>`;
}}
// ── 6. Traffic Policies ──────────────────────────────
function renderPolicies() {{
const lists = DATA.policy_lists || {{}};
const bindings = DATA.policy_port_bindings || {{}};
const entries = DATA.policy_entries || {{}};
const stats = DATA.policy_stats || {{}};
const portCfg = DATA.port_config || {{}};
const filters = DATA.l2_filters || {{}};
// Build filter name lookup
const filterNames = {{}};
for (const [k,v] of Object.entries(filters)) filterNames[k] = v.name || ('Filter-'+k);
const filterTypeMap = {{'0':'L2','1':'IPv4','2':'IPv6','3':'VList'}};
const actionMap = {{'1':'Drop','2':'Permit','3':'Mgmt/OAM','4':'EVC','5':'Deny'}};
// Policy lists + port bindings
let listRows = '';
for (const [id, pl] of Object.entries(lists)) {{
// Find ports bound to this list
const ports = [];
for (const [portIdx, b] of Object.entries(bindings)) {{
if (b.policyListId === id) {{
const name = (portCfg[portIdx] || {{}}).name || ('Port-'+portIdx);
ports.push(name);
}}
}}
listRows += `<tr>
<td class="mono">${{id}}</td>
<td><strong>${{esc(pl.name)}}</strong></td>
<td class="mono">${{pl.nbrEntries}}</td>
<td>${{ports.length ? ports.map(p => `<span class="badge bg-secondary me-1">${{esc(p)}}</span>`).join('') : '<span class="status-na">none</span>'}}</td>
</tr>`;
}}
// Enabled policy entries grouped by list
let entryRows = '';
for (const [id, e] of Object.entries(entries)) {{
const listName = (lists[e.listId] || {{}}).name || e.listId;
const fType = filterTypeMap[e.filterType] || e.filterType;
const fName = filterNames[e.filterIndex] || ('idx:'+e.filterIndex);
const action = actionMap[e.action] || e.action;
const actionCls = e.action === '2' ? 'status-up' : e.action === '1' ? 'status-down' : '';
// Find matching stats
const st = stats[id] || {{}};
const pkts = st.inHCPkts ? parseInt(st.inHCPkts).toLocaleString() : '0';
const octets = st.inHCOctets ? formatBytes(st.inHCOctets) : '0 B';
entryRows += `<tr>
<td class="mono">${{id}}</td>
<td>${{esc(listName)}}</td>
<td class="mono">${{fType}}</td>
<td>${{esc(fName)}}</td>
<td class="${{actionCls}}">${{action}}</td>
<td class="mono">${{pkts}}</td>
<td class="mono">${{octets}}</td>
</tr>`;
}}
document.getElementById('sec-policies').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-shield-check"></i> Traffic Policies<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<h6 style="font-size:0.85rem;margin-bottom:0.5rem">Policy Lists &amp; Port Bindings</h6>
<table class="table table-dark-custom table-sm">
<thead><tr><th>ID</th><th>List Name</th><th>Max Entries</th><th>Bound Ports</th></tr></thead>
<tbody>${{listRows}}</tbody>
</table>
<h6 style="font-size:0.85rem;margin-top:1rem;margin-bottom:0.5rem">Enabled Policy Rules (${{Object.keys(entries).length}} active of 400 slots)</h6>
<div class="tbl-scroll" style="max-height:300px">
<table class="table table-dark-custom table-sm table-hover">
<thead><tr>
<th>#</th><th>List</th><th>Filter Type</th><th>Filter Name</th>
<th>Action</th><th>Matched Pkts</th><th>Matched Bytes</th>
</tr></thead>
<tbody>${{entryRows}}</tbody>
</table>
</div>
</div>
</div>`;
}}
// ── 7. L2 Filters ────────────────────────────────────
function renderFilters() {{
const filters = DATA.l2_filters || {{}};
let rows = '';
for (const [id, f] of Object.entries(filters)) {{
const conditions = [];
if (isTrue(f.macDstEn)) conditions.push('MAC Dst: ' + esc(f.macDst));
if (isTrue(f.macSrcEn)) conditions.push('MAC Src: ' + esc(f.macSrc));
if (isTrue(f.etypeEn)) conditions.push('EType: ' + esc(f.etype));
if (isTrue(f.vlan1IdEn)) conditions.push('VLAN1: ' + esc(f.vlan1Id));
if (isTrue(f.vlan2IdEn)) conditions.push('VLAN2: ' + esc(f.vlan2Id));
if (isTrue(f.vlan1PriorEn)) conditions.push('PCP1: ' + esc(f.vlan1Prior));
const condStr = conditions.length ? conditions.join(', ') : '<span class="status-na">any (catchall)</span>';
rows += `<tr>
<td class="mono">${{id}}</td>
<td><strong>${{esc(f.name)}}</strong></td>
<td style="font-size:0.8rem">${{condStr}}</td>
</tr>`;
}}
document.getElementById('sec-filters').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-funnel"></i> L2 Filters (${{Object.keys(filters).length}} defined)<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body" style="padding:0">
<div class="tbl-scroll" style="max-height:350px">
<table class="table table-dark-custom table-sm table-hover">
<thead><tr><th>ID</th><th>Name</th><th>Match Conditions</th></tr></thead>
<tbody>${{rows}}</tbody>
</table>
</div>
</div>
</div>`;
}}
// ── 8. Regulators ────────────────────────────────────
function renderRegulators() {{
const regs = DATA.regulators || {{}};
const regStats = DATA.regulator_stats || {{}};
const cos = DATA.cos_profiles || {{}};
let regRows = '';
for (const [id, r] of Object.entries(regs)) {{
const st = regStats[id] || {{}};
const cir = parseInt(r.cirKbps)||0;
const eir = parseInt(r.eirKbps)||0;
const cirStr = cir >= 1000 ? (cir/1000).toFixed(0)+' Mbps' : cir+' Kbps';
const eirStr = eir >= 1000 ? (eir/1000).toFixed(0)+' Mbps' : eir+' Kbps';
const cirMax = parseInt(r.cirMaxKbps)||0;
const cirMaxStr = cirMax >= 1000 ? (cirMax/1000).toFixed(0)+' Mbps' : cirMax+' Kbps';
const greenPkts = parseInt(st.greenHCPkts)||0;
const yellowPkts = parseInt(st.yellowHCPkts)||0;
const redPkts = parseInt(st.redHCPkts)||0;
const totalPkts = greenPkts + yellowPkts + redPkts;
const greenPct = totalPkts ? Math.round(greenPkts/totalPkts*100) : 0;
const yellowPct = totalPkts ? Math.round(yellowPkts/totalPkts*100) : 0;
const redPct = totalPkts ? Math.round(redPkts/totalPkts*100) : 0;
regRows += `<tr>
<td class="mono">${{id}}</td>
<td><strong>${{esc(r.name)}}</strong></td>
<td class="mono">${{cirStr}}</td>
<td class="mono">${{r.cbsKiB}} KiB</td>
<td class="mono">${{eirStr}}</td>
<td class="mono">${{r.ebsKiB}} KiB</td>
<td class="mono">${{cirMaxStr}}</td>
<td>${{r.isBlind==='1'?'Yes':'No'}}</td>
<td>${{r.workingRate==='1'?'L1':'L2'}}</td>
</tr>
<tr>
<td colspan="9" style="padding:0.3rem 1rem;border-top:0">
<div style="display:flex;gap:1.5rem;font-size:0.8rem">
<span style="color:var(--green)">Green: ${{greenPkts.toLocaleString()}} pkts (${{greenPct}}%)</span>
<span style="color:var(--amber)">Yellow: ${{yellowPkts.toLocaleString()}} pkts (${{yellowPct}}%)</span>
<span style="color:var(--red)">Red/Drop: ${{redPkts.toLocaleString()}} pkts (${{redPct}}%)</span>
<span style="color:var(--text-muted)">Accept: ${{formatBytes(st.acceptHCOctets||'0')}} | Drop: ${{formatBytes(st.dropHCOctets||'0')}}</span>
</div>
<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;margin-top:4px;background:#2d3340">
${{greenPct ? `<div style="width:${{greenPct}}%;background:var(--green)"></div>` : ''}}
${{yellowPct ? `<div style="width:${{yellowPct}}%;background:var(--amber)"></div>` : ''}}
${{redPct ? `<div style="width:${{redPct}}%;background:var(--red)"></div>` : ''}}
</div>
</td>
</tr>`;
}}
// CoS profiles
const cosTypeMap = {{'1':'PCP','2':'DSCP','3':'Precedence'}};
let cosRows = '';
for (const [id, c] of Object.entries(cos)) {{
cosRows += `<tr>
<td class="mono">${{id}}</td>
<td><strong>${{esc(c.name)}}</strong></td>
<td>${{cosTypeMap[c.type] || c.type}}</td>
<td>${{c.decodeDropBit==='1'?'Yes':'No'}}</td>
<td>${{c.encodeDropBit==='1'?'Yes':'No'}}</td>
</tr>`;
}}
document.getElementById('sec-regulators').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-speedometer2"></i> Bandwidth Regulators &amp; QoS<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<h6 style="font-size:0.85rem;margin-bottom:0.5rem">Regulators (${{Object.keys(regs).length}})</h6>
${{Object.keys(regs).length ? `
<table class="table table-dark-custom table-sm">
<thead><tr>
<th>ID</th><th>Name</th><th>CIR</th><th>CBS</th><th>EIR</th><th>EBS</th>
<th>CIR Max</th><th>Color-Blind</th><th>Rate Mode</th>
</tr></thead>
<tbody>${{regRows}}</tbody>
</table>
` : '<div style="color:#555;text-align:center;padding:1rem">No regulators configured</div>'}}
<h6 style="font-size:0.85rem;margin-top:1rem;margin-bottom:0.5rem">CoS Profiles (${{Object.keys(cos).length}})</h6>
<div class="tbl-scroll" style="max-height:250px">
<table class="table table-dark-custom table-sm">
<thead><tr><th>ID</th><th>Name</th><th>Type</th><th>Decode Drop</th><th>Encode Drop</th></tr></thead>
<tbody>${{cosRows}}</tbody>
</table>
</div>
</div>
</div>`;
}}
// ── 3. LLDP Topology ─────────────────────────────────
function parseRemotePlatform(sysDesc, sysName) {{
// Parse sysDescr to extract vendor/model/firmware
// Cisco IOS-XR: " 7.5.2, NCS-5500" or "Cisco IOS XR Software, Version 7.5.2"
// Cisco IOS: "Cisco IOS Software, ..."
// Accedian: "AMN-1000-GT-S"
sysDesc = (sysDesc || '').trim();
sysName = (sysName || '').trim();
// Cisco NCS-5500 / IOS-XR pattern: "7.5.2, NCS-5500"
let m = sysDesc.match(/(\d+\.\d+\.\d+),?\s+(NCS-\S+|ASR-\S+|XRv\S*)/i);
if (m) return {{ vendor: 'Cisco', model: m[2], firmware: 'IOS-XR ' + m[1] }};
// Cisco IOS pattern: "Cisco IOS Software, ... Version X.Y"
m = sysDesc.match(/Cisco IOS.*Version\s+(\S+)/i);
if (m) return {{ vendor: 'Cisco', model: sysName.split('.')[0], firmware: 'IOS ' + m[1] }};
// Accedian pattern
m = sysDesc.match(/(AMN-\S+|MetroNID)/i);
if (m) return {{ vendor: 'Accedian', model: m[1], firmware: sysDesc }};
// Generic: just show what we have
if (sysDesc) return {{ vendor: '', model: sysDesc, firmware: '' }};
return {{ vendor: '', model: 'Unknown', firmware: '' }};
}}
function renderLldp() {{
const neighbors = DATA.lldp_neighbors || {{}};
const ifaces = DATA.interfaces || {{}};
const sfpInfo = DATA.sfp_info || {{}};
const device = DATA.device || {{}};
const neighborByPort = {{}};
Object.values(neighbors).forEach(n => {{ neighborByPort[n.localPort] = n; }});
const connectors = DATA.connectors || {{}};
const localName = device.identifier || device.sysName || 'NID';
const localModel = device.commercialName || device.sysDescr || 'NID';
function slotState(portIdx) {{
const conn = connectors[portIdx];
const isSfp = conn && conn.type === '14';
const iface = ifaces[portIdx];
if (isSfp) {{
const sfp = sfpInfo[portIdx];
const present = sfp && isTrue(sfp.present);
if (!present) return 'empty';
if (iface && isUp(iface.ifOperStatus)) return 'present-link';
return 'present-nolink';
}} else {{
if (iface && isUp(iface.ifOperStatus)) return 'present-link';
if (iface && isDown(iface.ifOperStatus)) return 'present-nolink';
return 'empty';
}}
}}
function buildNeighborCard(nbr, cssClass) {{
const platform = parseRemotePlatform(nbr.remSysDesc, nbr.remSysName);
const shortName = (nbr.remSysName || '').split('.')[0] || 'Unknown';
const modelLine = platform.vendor ? `${{platform.vendor}} ${{platform.model}}` : platform.model;
return `<div class="${{cssClass}}">
<div class="remote-hostname">${{esc(shortName)}}</div>
<div class="remote-model">${{esc(modelLine)}}</div>
${{platform.firmware ? `<div class="remote-detail"><span class="rlabel">FW</span> ${{esc(platform.firmware)}}</div>` : ''}}
<div class="remote-detail"><span class="rlabel">Port</span> ${{esc(nbr.remPortId || '?')}}</div>
${{nbr.remPortDesc ? `<div class="remote-detail"><span class="rlabel">Desc</span> ${{esc(nbr.remPortDesc)}}</div>` : ''}}
<div class="remote-detail"><span class="rlabel">MAC</span> ${{esc(nbr.chassisId || '?')}}</div>
${{nbr.mgmtIPv4 ? `<div class="remote-mgmt"><i class="bi bi-globe2"></i> ${{esc(nbr.mgmtIPv4)}}</div>` : ''}}
${{nbr.mgmtIPv6 ? `<div class="remote-detail"><span class="rlabel">IPv6</span> <span style="font-size:0.65rem">${{esc(nbr.mgmtIPv6)}}</span></div>` : ''}}
<div class="remote-detail" style="margin-top:0.2rem">
<span class="rlabel">Caps</span>
${{nbr.capsEnabled === '2' ? '<span style="color:var(--amber)">Bridge</span>' :
nbr.capsEnabled === '4' ? '<span style="color:var(--cyan)">Router</span>' :
'Cap=' + (nbr.capsEnabled||'?')}}
</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>`;
}}
// Build vertical columns for data ports
const {{ dataPorts, mgmtPort }} = getPortLists();
let colsHtml = '';
for (const portKey of dataPorts) {{
const conn = connectors[portKey] || {{}};
const isSfp = conn.type === '14';
const nbr = neighborByPort[portKey];
const state = slotState(portKey);
const iface = ifaces[portKey] || {{}};
const localUp = isUp(iface.ifOperStatus);
const slotName = conn.name || (isSfp ? `SFP-${{portKey}}` : `RJ45-${{portKey}}`);
const icon = isSfp
? (state === 'empty' ? '<i class="bi bi-dash"></i>' :
state === 'present-link' ? '<i class="bi bi-arrow-left-right"></i>' :
'<i class="bi bi-plug"></i>')
: '<i class="bi bi-ethernet"></i>';
const portLabel = iface.ifName || 'Port ' + portKey;
if (nbr) {{
const linkClass = localUp ? 'up' : 'down';
colsHtml += `
<div class="lldp-col">
<div class="lldp-col-header ${{state}}">
${{icon}}<span class="slot-label">${{esc(slotName)}}</span>
</div>
<div class="lldp-col-port-label">${{esc(portLabel)}}</div>
<div class="lldp-col-line ${{linkClass}}"></div>
<div class="lldp-col-port-label">${{esc(nbr.remPortId || '?')}}</div>
${{buildNeighborCard(nbr, 'lldp-col-remote')}}
</div>`;
}} else {{
colsHtml += `
<div class="lldp-col idle">
<div class="lldp-col-header ${{state}}">
${{icon}}<span class="slot-label">${{esc(slotName)}}</span>
</div>
<div class="lldp-col-port-label">${{esc(portLabel)}}</div>
<div class="lldp-col-line idle"></div>
<div class="lldp-col-idle-label">No LLDP neighbor</div>
</div>`;
}}
}}
// MGMT column
const mgmtNbr = mgmtPort ? neighborByPort[mgmtPort] : null;
if (mgmtNbr) {{
const mgmtIface = ifaces[mgmtPort] || {{}};
const mgmtUp = isUp(mgmtIface.ifOperStatus);
colsHtml += `<div class="lldp-col-divider"></div>`;
colsHtml += `
<div class="lldp-col">
<div class="lldp-col-header ${{mgmtUp ? 'present-link' : 'present-nolink'}}">
<i class="bi bi-ethernet"></i><span class="slot-label">MGMT</span>
</div>
<div class="lldp-col-port-label">Management</div>
<div class="lldp-col-line ${{mgmtUp ? 'up' : 'down'}}"></div>
<div class="lldp-col-port-label">${{esc(mgmtNbr.remPortId || '?')}}</div>
${{buildNeighborCard(mgmtNbr, 'lldp-col-remote')}}
</div>`;
}}
document.getElementById('sec-lldp').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-diagram-3"></i> LLDP Topology<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<div class="lldp-panel">
<span class="panel-label">${{esc(localName)}} &mdash; ${{esc(localModel)}}</span>
<div class="lldp-columns">
${{colsHtml}}
</div>
</div>
</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 = isUp(qi.ifAdminStatus);
const operUp = isUp(qi.ifOperStatus);
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 = isUp(s.ifOperStatus);
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 ${{isUp(s.bvi_ifOperStatus) ? 'up' : 'down'}}">${{isUp(s.bvi_ifOperStatus) ? '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 = isUp(v.ifOperStatus);
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 ───────────────────────────────
function renderCoverage() {{
// Analyze each section for populated vs empty fields
const sections = [
{{ name: 'Device Identity', key: 'device', type: 'scalar' }},
{{ name: 'Interfaces (IF-MIB)', key: 'interfaces', type: 'table' }},
{{ name: 'Connectors', key: 'connectors', type: 'table' }},
{{ name: 'Power Supplies', key: 'power_supplies', type: 'table' }},
{{ name: 'Temperature Sensors', key: 'temperature_sensors', type: 'table' }},
{{ name: 'SFP Info', key: 'sfp_info', type: 'table' }},
{{ name: 'SFP Diagnostics', key: 'sfp_diagnostics', type: 'table' }},
{{ name: 'SFP Thresholds', key: 'sfp_thresholds', type: 'table' }},
{{ name: 'Alarm Config', key: 'alarm_config', type: 'table' }},
{{ name: 'Alarm Status', key: 'alarm_status', type: 'table' }},
{{ name: 'Alarm General', key: 'alarm_general', type: 'scalar' }},
{{ name: 'Port Config', key: 'port_config', type: 'table' }},
{{ name: 'Port Status', key: 'port_status', type: 'table' }},
{{ name: 'L2 Filters', key: 'l2_filters', type: 'table' }},
{{ name: 'Policy Lists', key: 'policy_lists', type: 'table' }},
{{ name: 'Policy Port Bindings', key: 'policy_port_bindings', type: 'table' }},
{{ name: 'Policy Entries (enabled)', key: 'policy_entries', type: 'table' }},
{{ name: 'Policy Stats', key: 'policy_stats', type: 'table' }},
{{ name: 'Regulators', key: 'regulators', type: 'table' }},
{{ name: 'Regulator Stats', key: 'regulator_stats', type: 'table' }},
{{ name: 'CoS Profiles', key: 'cos_profiles', type: 'table' }},
{{ name: 'LLDP Neighbors', key: 'lldp_neighbors', type: 'table' }},
{{ name: 'LLDP Stats', key: 'lldp_stats', type: 'table' }},
];
function analyzeSection(sec) {{
const d = DATA[sec.key];
if (!d) return {{ total: 0, populated: 0, empty: 0 }};
let total = 0, populated = 0;
if (sec.type === 'scalar') {{
for (const [k,v] of Object.entries(d)) {{
total++;
if (isPopulated(v)) populated++;
}}
}} else {{
for (const [idx, row] of Object.entries(d)) {{
for (const [k,v] of Object.entries(row)) {{
total++;
if (isPopulated(v)) populated++;
}}
}}
}}
return {{ total, populated, empty: total - populated }};
}}
let tableRows = '';
for (const sec of sections) {{
const a = analyzeSection(sec);
const pct = a.total ? Math.round(a.populated / a.total * 100) : 0;
const barColor = pct > 70 ? 'var(--green)' : pct > 30 ? 'var(--amber)' : 'var(--red)';
tableRows += `<tr>
<td>${{esc(sec.name)}}</td>
<td class="mono">${{a.total}}</td>
<td class="mono">${{a.populated}}</td>
<td class="mono">${{a.empty}}</td>
<td>
<div class="d-flex align-items-center gap-2">
<div class="cov-bar-track flex-grow-1">
<div class="cov-bar-fill" style="width:${{pct}}%;background:${{barColor}}"></div>
</div>
<span class="mono" style="min-width:35px">${{pct}}%</span>
</div>
</td>
</tr>`;
}}
// OID module distribution
const modCounts = DATA._module_oid_counts || {{}};
const totalOids = Object.values(modCounts).reduce((a,b) => a+b, 0);
let modRows = '';
for (const [mod, count] of Object.entries(modCounts)) {{
const pct = totalOids ? Math.round(count/totalOids*100) : 0;
modRows += `<tr>
<td class="mono">${{esc(mod)}}</td>
<td class="mono">${{count.toLocaleString()}}</td>
<td>
<div class="d-flex align-items-center gap-2">
<div class="oid-bar-track flex-grow-1">
<div class="oid-bar-fill" style="width:${{pct}}%"></div>
</div>
<span class="mono" style="min-width:35px">${{pct}}%</span>
</div>
</td>
</tr>`;
}}
// Known gaps
const gaps = [];
// Check SFP DDM
const sfpInfo = DATA.sfp_info || {{}};
for (const [k,s] of Object.entries(sfpInfo)) {{
if (isTrue(s.present) && !isTrue(s.diagCapable)) {{
gaps.push({{
type: 'red',
text: `SFP-${{k}} DDM: Not available via SNMP (diagCapable=false). NID web UI reads SFP I2C bus directly.`
}});
}}
}}
// Check Feed B
const pwr = DATA.power_supplies || {{}};
for (const [k,p] of Object.entries(pwr)) {{
if (p.present === '2') gaps.push({{ type: 'warn', text: `${{p.name}}: Not present` }});
}}
// Check LLDP completeness
const lldpNbrs = Object.keys(DATA.lldp_neighbors || {{}});
if (lldpNbrs.length === 0) {{
gaps.push({{ type: 'warn', text: 'LLDP: No neighbor data available' }});
}}
let gapHtml = gaps.map(g =>
`<div class="gap-callout ${{g.type==='warn'?'warn':''}}">${{esc(g.text)}}</div>`
).join('');
document.getElementById('sec-coverage').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-bar-chart"></i> SNMP Data Coverage<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<h6 style="font-size:0.85rem;margin-bottom:0.5rem">Field Population by Section</h6>
<p style="font-size:0.75rem;color:var(--text-muted)">Shows how many fields contain real (non-zero, non-empty) data vs. blank/zero values. This tells you what can be meaningfully polled.</p>
<div class="tbl-scroll" style="max-height:none">
<table class="table table-dark-custom table-sm">
<thead><tr>
<th>Section</th><th>Total Fields</th><th>Populated</th><th>Empty/Zero</th><th>Coverage</th>
</tr></thead>
<tbody>${{tableRows}}</tbody>
</table>
</div>
${{gapHtml ? `<h6 style="font-size:0.85rem;margin-top:1rem;margin-bottom:0.5rem">Known Data Gaps</h6>${{gapHtml}}` : ''}}
<h6 style="font-size:0.85rem;margin-top:1rem;margin-bottom:0.5rem">OID Distribution by MIB Module</h6>
<p style="font-size:0.75rem;color:var(--text-muted)">${{totalOids.toLocaleString()}} total OIDs across ${{Object.keys(modCounts).length}} modules</p>
<div class="tbl-scroll" style="max-height:none">
<table class="table table-dark-custom table-sm">
<thead><tr><th>Module</th><th>OIDs</th><th>Distribution</th></tr></thead>
<tbody>${{modRows}}</tbody>
</table>
</div>
</div>
</div>`;
}}
// ── 8. Port Config vs Status ─────────────────────────
function renderPortCmp() {{
const portCfg = DATA.port_config || {{}};
const portStatus = DATA.port_status || {{}};
const speedMap = {{'0': 'Auto/--', '10': '10 Mbps', '100': '100 Mbps', '1000': '1 Gbps', '10000': '10 Gbps'}};
const duplexMap = {{'0': '--', '1': 'Half', '2': 'Full'}};
const boolMap = {{'1': 'Enabled', '2': 'Disabled'}};
const linkMap = {{'0': 'Down', '1': 'Up'}};
const statusSpeedMap = {{'0': '--', '1': '10M', '2': '1G', '3': '10G'}};
let rows = '';
const keys = Object.keys(portCfg).sort((a,b) => parseInt(a)-parseInt(b));
for (const idx of keys) {{
const cfg = portCfg[idx];
const st = portStatus[idx] || {{}};
const cfgSpeed = speedMap[cfg.speed] || cfg.speed;
const stSpeed = statusSpeedMap[st.speed] || st.speed || '--';
const cfgDuplex = duplexMap[cfg.duplex] || cfg.duplex;
const stDuplex = duplexMap[st.duplex] || st.duplex || '--';
const linkSt = linkMap[st.linkStatus] || st.linkStatus || '--';
const linkCls = st.linkStatus === '1' ? 'status-up' : 'status-down';
// Mismatch detection: if configured != operational (simplified)
const speedMismatch = cfg.speed && st.speed && cfg.speed !== '0' && st.speed !== '0' && cfgSpeed !== stSpeed ? 'mismatch' : '';
rows += `<tr>
<td class="mono">${{idx}}</td>
<td><strong>${{esc(cfg.name)}}</strong></td>
<td class="${{linkCls}}">${{linkSt}}</td>
<td>${{boolMap[cfg.autoNego] || cfg.autoNego || '--'}}</td>
<td class="mono ${{speedMismatch}}">${{cfgSpeed}}</td>
<td class="mono ${{speedMismatch}}">${{stSpeed}}</td>
<td class="mono">${{cfgDuplex}}</td>
<td class="mono">${{stDuplex}}</td>
<td class="mono">${{cfg.mtu}}</td>
<td class="mono">${{boolMap[cfg.state] || cfg.state || '--'}}</td>
<td class="mono">${{boolMap[cfg.pauseMode] || cfg.pauseMode || '--'}}</td>
<td class="mono">${{boolMap[cfg.forceTxOn] || cfg.forceTxOn || '--'}}</td>
</tr>`;
}}
document.getElementById('sec-portcmp').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-sliders"></i> Port Configuration vs. Operational Status<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body" style="padding:0">
<div style="padding:0.4rem 1rem;font-size:0.75rem;color:var(--text-muted)">
Configured values from ACD-PORT-MIB alongside operational status. <span style="background:rgba(253,126,20,0.12);padding:1px 4px;border-radius:2px">Orange highlight</span> = config/status mismatch.
</div>
<div class="tbl-scroll">
<table class="table table-dark-custom table-sm table-hover">
<thead><tr>
<th>#</th><th>Name</th><th>Link</th><th>AutoNeg</th>
<th>Cfg Speed</th><th>Oper Speed</th>
<th>Cfg Duplex</th><th>Oper Duplex</th>
<th>MTU</th><th>State</th><th>Pause</th><th>Force TX</th>
</tr></thead>
<tbody>${{rows}}</tbody>
</table>
</div>
</div>
</div>`;
}}
// ── Render all sections ──────────────────────────────
renderWalkControl();
renderHeader();
renderMap();
renderPanel();
renderLldp();
renderNeighbor();
renderInterfaces();
renderSfp();
renderAlarms();
renderPolicies();
renderFilters();
renderRegulators();
renderCoverage();
renderPortCmp();
// Auto-select SFP-1
selectSfp(1);
// Attach collapsible click handlers
initCollapsible();
</script>
</body>
</html>'''
def main():
input_file = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else DEFAULT_INPUT
if not input_file.is_file():
print(f"Error: {input_file} not found", file=sys.stderr)
sys.exit(1)
output_file = input_file.parent / "nid-viewer.html"
print(f"Reading: {input_file}")
with input_file.open(encoding="utf-8") as f:
data = json.load(f)
html = build_html(data)
with output_file.open("w", encoding="utf-8") as f:
f.write(html)
print(f"Written: {output_file}")
print(f"Size: {len(html):,} bytes")
print(f"Open in browser to view.")
if __name__ == "__main__":
main()