- 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>
2439 lines
95 KiB
Python
2439 lines
95 KiB
Python
#!/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 — {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>
|
||
|
||
<!-- ═══════════════ 5–6. 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>
|
||
|
||
<!-- ═══════════════ 8–9. 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}}
|
||
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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}}°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
|
||
<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
|
||
<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 & 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)}} — 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)}}°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 — 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"> </span> Critical: ${{sevCounts[3]}}</span>
|
||
<span><span class="badge badge-sev-2"> </span> Major: ${{sevCounts[2]}}</span>
|
||
<span><span class="badge badge-sev-1"> </span> Minor: ${{sevCounts[1]}}</span>
|
||
<span><span class="badge badge-sev-0"> </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 & 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 & 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)}} — ${{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()
|