diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ffd030f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,26 @@
+# ── SNMP NID Viewer – Environment Configuration ──
+# Copy to .env and edit as needed. This file is gitignored.
+
+# Target device IP (override via CLI: ./snmp-walk.sh 10.0.0.1)
+SNMP_TARGET=10.13.60.102
+
+# ── SNMPv2c ──
+SNMP_VERSION=2c
+SNMP_COMMUNITY=public
+
+# ── SNMPv3 (future – uncomment and set when needed) ──
+# SNMP_VERSION=3
+# SNMP_V3_USER=
+# SNMP_V3_AUTH_PROTO=SHA # MD5 | SHA | SHA-256 | SHA-512
+# SNMP_V3_AUTH_PASS=
+# SNMP_V3_PRIV_PROTO=AES # DES | AES | AES-256
+# SNMP_V3_PRIV_PASS=
+# SNMP_V3_SEC_LEVEL=authPriv # noAuthNoPriv | authNoPriv | authPriv
+
+# ── Walk mode ──
+# "full" = walk entire .1 tree (captures everything, ~27% larger)
+# "targeted" = walk only subtrees used by the viewer (faster)
+SNMP_WALK_MODE=targeted
+
+# ── Server ──
+SERVER_PORT=5525
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..71a09f2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+# Environment secrets
+.env
+
+# Python
+__pycache__/
+
+# Walk data (device-specific, generated)
+walks/
diff --git a/build_nid_viewer.py b/build_nid_viewer.py
index 75bfbf2..1e76e8d 100644
--- a/build_nid_viewer.py
+++ b/build_nid_viewer.py
@@ -76,6 +76,7 @@ def build_html(data: dict) -> str:
--major: #fd7e14;
--minor: #ffc107;
--info-sev: #17a2b8;
+ --cyan: #06b6d4;
}}
body {{
background: var(--bg-dark);
@@ -100,7 +101,24 @@ body {{
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);
@@ -179,6 +197,22 @@ body {{
.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;
+ white-space: nowrap;
+ line-height: 1.2;
+}}
.mgmt-port {{
width: 40px;
height: 44px;
@@ -278,6 +312,100 @@ body {{
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-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; }}
+}}
+.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; }}
@@ -312,79 +440,90 @@ body {{
.tbl-scroll::-webkit-scrollbar-track {{ background: var(--bg-card); }}
.tbl-scroll::-webkit-scrollbar-thumb {{ background: var(--border-color); border-radius: 3px; }}
-/* LLDP topology diagram */
-.topo-container {{
- display: flex;
- align-items: stretch;
- gap: 0;
- overflow-x: auto;
- padding: 1rem 0;
+/* LLDP topology diagram (redesigned) */
+.lldp-panel {{
+ background: #16181f;
+ border: 2px solid #3a3f4b;
+ border-radius: 6px;
+ padding: 1.2rem 1.5rem;
+ position: relative;
}}
-.topo-device {{
+.lldp-panel .panel-label {{
+ position: absolute;
+ top: -10px;
+ left: 12px;
background: var(--bg-card);
- border: 2px solid var(--border-color);
- border-radius: 8px;
- padding: 1rem 1.2rem;
- min-width: 220px;
- max-width: 300px;
- flex-shrink: 0;
-}}
-.topo-device.local {{
- border-color: var(--accent);
-}}
-.topo-device.remote {{
- border-color: var(--cyan);
-}}
-.topo-device .topo-hostname {{
- font-size: 1rem;
- font-weight: 700;
- color: var(--text-primary);
- margin-bottom: 0.3rem;
- word-break: break-all;
-}}
-.topo-device .topo-model {{
- font-size: 0.8rem;
- color: var(--cyan);
- margin-bottom: 0.5rem;
-}}
-.topo-device .topo-detail {{
- font-size: 0.75rem;
+ padding: 0 6px;
+ font-size: 0.7rem;
color: var(--text-muted);
- margin: 0.15rem 0;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}}
-.topo-device .topo-detail .label {{
- color: #666;
- min-width: 40px;
- display: inline-block;
+.lldp-row {{
+ display: flex;
+ align-items: center;
+ gap: 0;
+ margin-bottom: 0.75rem;
}}
-.topo-device .topo-mgmt {{
- font-size: 0.8rem;
- color: var(--green);
- font-weight: 600;
- margin-top: 0.4rem;
- font-family: 'JetBrains Mono', monospace;
-}}
-.topo-link {{
+.lldp-row:last-child {{ margin-bottom: 0; }}
+.lldp-local-slot {{
+ width: 56px;
+ height: 44px;
+ border-radius: 4px;
+ border: 2px solid #3a3f4b;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- min-width: 120px;
- max-width: 200px;
+ font-size: 0.65rem;
+ font-weight: 600;
flex-shrink: 0;
- position: relative;
- padding: 0 0.5rem;
}}
-.topo-link .link-line {{
+.lldp-local-slot.present-link {{
+ background: rgba(34,197,94,0.15);
+ border-color: var(--green);
+ color: var(--green);
+}}
+.lldp-local-slot.present-nolink {{
+ background: rgba(245,158,11,0.15);
+ border-color: var(--amber);
+ color: var(--amber);
+}}
+.lldp-local-slot.empty {{
+ background: #1a1d24;
+ border-color: #2d3340;
+ color: #555;
+}}
+.lldp-local-slot .slot-label {{
+ font-size: 0.6rem;
+ color: var(--text-muted);
+}}
+.lldp-connector {{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-width: 80px;
+ max-width: 200px;
+ flex: 1;
+ padding: 0 0.4rem;
+}}
+.lldp-connector .link-line {{
width: 100%;
height: 3px;
border-radius: 2px;
position: relative;
}}
-.topo-link .link-line.up {{ background: var(--green); box-shadow: 0 0 8px rgba(34,197,94,0.3); }}
-.topo-link .link-line.down {{ background: var(--amber); box-shadow: 0 0 8px rgba(245,158,11,0.3); }}
-.topo-link .link-line::before,
-.topo-link .link-line::after {{
+.lldp-connector .link-line.up {{
+ background: var(--green);
+ box-shadow: 0 0 8px rgba(34,197,94,0.3);
+}}
+.lldp-connector .link-line.down {{
+ background: var(--amber);
+ box-shadow: 0 0 8px rgba(245,158,11,0.3);
+}}
+.lldp-connector .link-line::before,
+.lldp-connector .link-line::after {{
content: '';
position: absolute;
top: 50%;
@@ -393,41 +532,69 @@ body {{
border-radius: 50%;
transform: translateY(-50%);
}}
-.topo-link .link-line.up::before,
-.topo-link .link-line.up::after {{ background: var(--green); }}
-.topo-link .link-line.down::before,
-.topo-link .link-line.down::after {{ background: var(--amber); }}
-.topo-link .link-line::before {{ left: -4px; }}
-.topo-link .link-line::after {{ right: -4px; }}
-.topo-link .link-label {{
- font-size: 0.65rem;
+.lldp-connector .link-line.up::before,
+.lldp-connector .link-line.up::after {{ background: var(--green); }}
+.lldp-connector .link-line.down::before,
+.lldp-connector .link-line.down::after {{ background: var(--amber); }}
+.lldp-connector .link-line::before {{ left: -4px; }}
+.lldp-connector .link-line::after {{ right: -4px; }}
+.lldp-connector .link-port-label {{
+ font-size: 0.6rem;
color: var(--text-muted);
text-align: center;
white-space: nowrap;
- margin: 0.3rem 0;
font-family: 'JetBrains Mono', monospace;
+ margin: 0.2rem 0;
}}
-.topo-port-list {{
- list-style: none;
- padding: 0;
- margin: 0.5rem 0 0 0;
- font-size: 0.75rem;
-}}
-.topo-port-list li {{
- display: flex;
- align-items: center;
- gap: 0.4rem;
- padding: 0.1rem 0;
-}}
-.topo-port-list .dot {{
- width: 8px;
- height: 8px;
- border-radius: 50%;
+.lldp-remote {{
+ background: #1e2128;
+ border: 1px solid #3a3f4b;
+ border-radius: 4px;
+ padding: 0.5rem 0.75rem;
+ min-width: 200px;
+ max-width: 320px;
flex-shrink: 0;
}}
-.topo-port-list .dot.up {{ background: var(--green); }}
-.topo-port-list .dot.down {{ background: #555; }}
-.topo-port-list .dot.linked {{ background: var(--green); box-shadow: 0 0 4px rgba(34,197,94,0.5); }}
+.lldp-remote .remote-hostname {{
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: var(--text-main);
+ word-break: break-all;
+}}
+.lldp-remote .remote-model {{
+ font-size: 0.72rem;
+ color: var(--cyan);
+ margin-bottom: 0.3rem;
+}}
+.lldp-remote .remote-detail {{
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ margin: 0.1rem 0;
+ font-family: 'JetBrains Mono', monospace;
+}}
+.lldp-remote .remote-detail .rlabel {{
+ color: #555;
+ display: inline-block;
+ min-width: 32px;
+}}
+.lldp-remote .remote-mgmt {{
+ font-size: 0.75rem;
+ color: var(--green);
+ font-weight: 600;
+ margin-top: 0.3rem;
+ font-family: 'JetBrains Mono', monospace;
+}}
+.lldp-row.idle {{
+ opacity: 0.4;
+}}
+.lldp-row.idle .lldp-connector {{
+ min-width: 40px;
+}}
+.lldp-idle-label {{
+ font-size: 0.7rem;
+ color: #555;
+ font-style: italic;
+}}
.topo-stats-table {{
width: 100%;
margin-top: 1rem;
@@ -448,33 +615,36 @@ body {{
+
+
+
-
+
+
+
+
-
+
-
+
-
+
-
+
-
+
-
-
-
@@ -543,6 +713,174 @@ function isPopulated(v) {{
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 = `
+
+
+
+
+
+ Target IP
+
+ Mode
+
+ Targeted
+ Full
+
+
+ Walk
+
+
+ Clear
+
+
+
+
+ Idle
+
+
+
`;
+}}
+
+let walkEventSource = null;
+
+function startWalk() {{
+ const target = document.getElementById('walk-target').value.trim();
+ const mode = document.getElementById('walk-mode').value;
+ const btn = document.getElementById('walk-btn');
+
+ if (!target) {{
+ updateWalkStatus('error', 'Enter a target IP address');
+ return;
+ }}
+
+ btn.disabled = true;
+ btn.innerHTML = '
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 }})
+ }})
+ .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;
+ // Reload to pick up fresh data
+ 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;
+ // SSE closed — could be server-side close after complete/error
+ // Check if we already handled it; if not, it was an unexpected close
+ }};
+ }})
+ .catch(err => {{
+ updateWalkStatus('error', 'Failed to connect: ' + 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 = `
${{esc(message)}} `;
+}}
+
+function resetWalkBtn() {{
+ const btn = document.getElementById('walk-btn');
+ if (btn) {{
+ btn.disabled = false;
+ btn.innerHTML = '
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 || {{}};
@@ -560,26 +898,50 @@ function renderHeader() {{
}}
}}
+ const hasAlarmStatus = Object.keys(alarmStatus).length > 0;
let alarmBadge = '';
- if (activeCount > 0) {{
- const parts = [];
- if (sevCounts[3]) parts.push(`
${{sevCounts[3]}} CRIT `);
- if (sevCounts[2]) parts.push(`
${{sevCounts[2]}} MAJ `);
- if (sevCounts[1]) parts.push(`
${{sevCounts[1]}} MIN `);
- if (sevCounts[0]) parts.push(`
${{sevCounts[0]}} INFO `);
- alarmBadge = `
${{activeCount}} Active Alarms ${{parts.join(' ')}}`;
+ if (hasAlarmStatus) {{
+ if (activeCount > 0) {{
+ const parts = [];
+ if (sevCounts[3]) parts.push(`
${{sevCounts[3]}} CRIT `);
+ if (sevCounts[2]) parts.push(`
${{sevCounts[2]}} MAJ `);
+ if (sevCounts[1]) parts.push(`
${{sevCounts[1]}} MIN `);
+ if (sevCounts[0]) parts.push(`
${{sevCounts[0]}} INFO `);
+ alarmBadge = `
${{activeCount}} Active Alarms ${{parts.join(' ')}}`;
+ }} else {{
+ alarmBadge = '
No Active Alarms ';
+ }}
+ }} else if (Object.keys(alarmCfg).length > 0) {{
+ alarmBadge = `
${{Object.keys(alarmCfg).length}} Alarm Defs `;
+ }}
+
+ // 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 ? `
on ${{esc(ifName)}} ` : '';
+ return `
${{esc(ip.address)}}/${{esc(ip.prefixLength)}} ${{suffix}}`;
+ }});
+ mgmtIpHtml = ipItems.join('
');
}} else {{
- alarmBadge = '
No Active Alarms ';
+ mgmtIpHtml = '
Not available ';
}}
document.getElementById('sec-header').innerHTML = `
-