Compare commits

..

No commits in common. "edge-case-metronode" and "main" have entirely different histories.

34 changed files with 252 additions and 285074 deletions

View File

@ -22,10 +22,5 @@ SNMP_COMMUNITY=public
# "targeted" = walk only subtrees used by the viewer (faster)
SNMP_WALK_MODE=targeted
# ── Policy data ──
# ACD-POLICY-MIB is ~73% of all OIDs. Set to "false" to skip it for faster walks.
# The Traffic Policies card will be empty when disabled.
SNMP_WALK_POLICIES=true
# ── Server ──
SERVER_PORT=5525

View File

@ -60,8 +60,6 @@ def build_html(data: dict) -> str:
<title>NID Viewer &mdash; {page_title}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root {{
--bg-dark: #0f1117;
@ -163,7 +161,7 @@ body {{
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
gap: 1.2rem;
flex-wrap: wrap;
position: relative;
}}
@ -212,8 +210,8 @@ body {{
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
min-height: 1.8em;
}}
.mgmt-port {{
width: 40px;
@ -351,20 +349,6 @@ body {{
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;
@ -475,21 +459,14 @@ body {{
text-transform: uppercase;
letter-spacing: 0.05em;
}}
/* LLDP vertical columns */
.lldp-columns {{
.lldp-row {{
display: flex;
gap: 1rem;
align-items: stretch;
}}
.lldp-col {{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
gap: 0;
margin-bottom: 0.75rem;
}}
.lldp-col.idle {{ opacity: 0.4; }}
.lldp-col-header {{
.lldp-row:last-child {{ margin-bottom: 0; }}
.lldp-local-slot {{
width: 56px;
height: 44px;
border-radius: 4px;
@ -502,178 +479,147 @@ body {{
font-weight: 600;
flex-shrink: 0;
}}
.lldp-col-header.present-link {{
.lldp-local-slot.present-link {{
background: rgba(34,197,94,0.15);
border-color: var(--green);
color: var(--green);
}}
.lldp-col-header.present-nolink {{
.lldp-local-slot.present-nolink {{
background: rgba(245,158,11,0.15);
border-color: var(--amber);
color: var(--amber);
}}
.lldp-col-header.empty {{
.lldp-local-slot.empty {{
background: #1a1d24;
border-color: #2d3340;
color: #555;
}}
.lldp-col-header .slot-label {{
.lldp-local-slot .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-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-col-line {{
width: 3px;
height: 36px;
.lldp-connector .link-line {{
width: 100%;
height: 3px;
border-radius: 2px;
margin: 0.15rem 0;
position: relative;
}}
.lldp-col-line.up {{
.lldp-connector .link-line.up {{
background: var(--green);
box-shadow: 0 0 8px rgba(34,197,94,0.3);
}}
.lldp-col-line.down {{
.lldp-connector .link-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 {{
.lldp-connector .link-line::before,
.lldp-connector .link-line::after {{
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
transform: translateX(-50%);
transform: translateY(-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 {{
.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;
font-family: 'JetBrains Mono', monospace;
margin: 0.2rem 0;
}}
.lldp-remote {{
background: #1e2128;
border: 1px solid #3a3f4b;
border-radius: 4px;
padding: 0.5rem 0.75rem;
width: 100%;
margin-top: 0.15rem;
flex: 1;
min-width: 200px;
max-width: 320px;
flex-shrink: 0;
}}
.lldp-col-remote .remote-hostname {{
.lldp-remote .remote-hostname {{
font-size: 0.85rem;
font-weight: 700;
color: var(--text-main);
word-break: break-all;
}}
.lldp-col-remote .remote-model {{
.lldp-remote .remote-model {{
font-size: 0.72rem;
color: var(--cyan);
margin-bottom: 0.3rem;
}}
.lldp-col-remote .remote-detail {{
.lldp-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 {{
.lldp-remote .remote-detail .rlabel {{
color: #555;
display: inline-block;
min-width: 32px;
}}
.lldp-col-remote .remote-mgmt {{
.lldp-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 {{
.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;
margin-top: 0.3rem;
}}
.lldp-col-divider {{
width: 1px;
align-self: stretch;
border-left: 1px dashed #3a3f4b;
margin: 0 0.25rem;
}}
/* 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 {{
.topo-stats-table {{
width: 100%;
margin-top: 1rem;
font-size: 0.75rem;
}}
.topo-stats-table th {{
color: var(--text-muted);
margin-top: 0.4rem;
font-weight: 500;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--border-color);
}}
.topo-stats-table td {{
padding: 0.3rem 0.5rem;
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">
<div class="container-fluid py-3" style="max-width:1400px">
<!-- 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>
<!-- 1. DEVICE HEADER -->
<div id="sec-header"></div>
<!-- 2. FRONT PANEL -->
<div id="sec-panel"></div>
@ -684,20 +630,20 @@ body {{
<!-- 4. INTERFACES TABLE -->
<div id="sec-interfaces"></div>
<!-- 56. SFP + ALARMS (side by side) -->
<div style="display:flex;gap:1rem;align-items:stretch">
<div id="sec-sfp" style="flex:1;min-width:0;display:flex;flex-direction:column"></div>
<div id="sec-alarms" style="flex:1;min-width:0;display:flex;flex-direction:column"></div>
</div>
<!-- 5. SFP CARDS -->
<div id="sec-sfp"></div>
<!-- 6. ALARMS -->
<div id="sec-alarms"></div>
<!-- 7. TRAFFIC POLICIES -->
<div id="sec-policies"></div>
<!-- 89. L2 FILTERS + REGULATORS (side by side) -->
<div style="display:flex;gap:1rem;align-items:stretch">
<div id="sec-filters" style="flex:1;min-width:0;display:flex;flex-direction:column"></div>
<div id="sec-regulators" style="flex:1;min-width:0;display:flex;flex-direction:column"></div>
</div>
<!-- 8. L2 FILTERS -->
<div id="sec-filters"></div>
<!-- 9. REGULATORS -->
<div id="sec-regulators"></div>
<!-- 10. COVERAGE MATRIX -->
<div id="sec-coverage"></div>
@ -734,26 +680,6 @@ function formatUptime(sec) {{
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;
}}
@ -849,10 +775,6 @@ function renderWalkControl() {{
<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>
@ -873,7 +795,6 @@ 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) {{
@ -882,78 +803,55 @@ function startWalk() {{
}}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Pinging...';
updateWalkStatus('running', 'Checking reachability...');
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Walking...';
updateWalkStatus('running', 'Starting walk...');
// Step 1: Ping check
fetch('/api/ping', {{
// Close any previous SSE connection
if (walkEventSource) walkEventSource.close();
fetch('/api/walk', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ target }})
body: JSON.stringify({{ target, mode }})
}})
.then(r => r.json())
.then(ping => {{
if (!ping.reachable) {{
updateWalkStatus('error', 'NID is DOWN. Verify Local Power and Router Interface Status.');
.then(resp => {{
if (resp.error) {{
updateWalkStatus('error', resp.error);
resetWalkBtn();
return;
}}
updateWalkStatus('complete', 'NID Management is UP');
// 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 + '%';
// 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);
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();
}});
}}, 600);
}} 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', 'Ping check failed: ' + err.message);
updateWalkStatus('error', 'Failed to connect: ' + err.message);
resetWalkBtn();
}});
}}
@ -993,7 +891,7 @@ function renderHeader() {{
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)) {{
if (a.active === '1') {{
activeCount++;
const cfg = cfgByNum[a.number];
if (cfg) sevCounts[cfg.severity] = (sevCounts[cfg.severity]||0) + 1;
@ -1085,40 +983,6 @@ function renderHeader() {{
</div>`;
}}
// 1b. Location Map
function renderMap() {{
const d = DATA.device || {{}};
const loc = (d.sysLocation || '').trim();
const m = loc.match(/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/);
if (!m) return; // no valid coordinates skip map
const lat = parseFloat(m[1]);
const lon = parseFloat(m[2]);
const hostname = d.sysName || d.identifier || 'NID';
document.getElementById('sec-map').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-geo-alt"></i> Location Map<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<div id="nid-map"></div>
<div class="map-coords"><i class="bi bi-crosshair"></i> ${{lat.toFixed(6)}}, ${{lon.toFixed(6)}}</div>
</div>
</div>`;
const map = L.map('nid-map').setView([lat, lon], 15);
L.tileLayer('https://{{s}}.basemaps.cartocdn.com/dark_all/{{z}}/{{x}}/{{y}}{{r}}.png', {{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19
}}).addTo(map);
L.marker([lat, lon]).addTo(map)
.bindPopup(`<b>${{esc(hostname)}}</b><br>${{lat.toFixed(6)}}, ${{lon.toFixed(6)}}`)
.openPopup();
// Leaflet needs a resize nudge when rendered in a hidden/collapsed container
setTimeout(() => map.invalidateSize(), 200);
}}
// 2. Front Panel
function renderPanel() {{
const connectors = DATA.connectors || {{}};
@ -1140,7 +1004,7 @@ function renderPanel() {{
const iface = ifaces[connIdx];
if (connType(connIdx) === 'sfp') {{
const sfp = sfpInfo[connIdx];
const present = sfp && isTrue(sfp.present);
const present = sfp && sfp.present === '1';
if (!present) return 'empty';
if (iface && isUp(iface.ifOperStatus)) return 'present-link';
return 'present-nolink';
@ -1155,7 +1019,7 @@ function renderPanel() {{
function sfpLabel(connIdx) {{
const sfp = sfpInfo[connIdx];
if (!sfp) return '';
if (!isTrue(sfp.present)) return 'EMPTY';
if (sfp.present !== '1') return 'EMPTY';
const pn = sfp.vendorPn || '';
if (pn.length > 8) return pn.substring(0,8);
return pn || sfp.vendor || '';
@ -1170,15 +1034,15 @@ function renderPanel() {{
return {{ label, alias }};
}}
// Build port slots dynamically from connectors
const {{ dataPorts, mgmtPort }} = getPortLists();
// Build port slots dynamically from connectors 1-4
let slots = '';
for (const idx of dataPorts) {{
for (let i = 1; i <= 4; i++) {{
const idx = String(i);
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}}`);
const slotName = conn.name || (isSfp ? `SFP-${{i}}` : `RJ45-${{i}}`);
let icon, detail;
if (isSfp) {{
@ -1198,7 +1062,7 @@ function renderPanel() {{
? `<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}})">
<div class="sfp-slot ${{state}}" data-sfp="${{i}}" onclick="selectSfp(${{i}})">
${{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>
@ -1207,7 +1071,7 @@ function renderPanel() {{
}}
// Management port
const mgmtIf = mgmtPort ? ifaces[mgmtPort] : null;
const mgmtIf = ifaces['5'];
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>
@ -1217,7 +1081,7 @@ function renderPanel() {{
// Power feeds
let pwrHtml = '<div class="pwr-block">';
for (const [k,p] of Object.entries(pwr)) {{
const ok = isTrue(p.present);
const ok = p.present === '1';
pwrHtml += `<div><span class="pwr-led ${{ok?'ok':'fail'}}"></span>${{esc(p.name)}} ${{ok?'OK':'ABSENT'}}</div>`;
}}
pwrHtml += '</div>';
@ -1331,27 +1195,27 @@ function renderSfp() {{
const sfpDiag = DATA.sfp_diagnostics || {{}};
const sfpThresh = DATA.sfp_thresholds || {{}};
const {{ dataPorts }} = getPortLists();
let cards = '';
for (const si of dataPorts) {{
for (let i = 1; i <= 4; i++) {{
const si = String(i);
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}}">
if (!info || info.present !== '1') {{
cards += `<div class="sfp-detail" id="sfp-detail-${{i}}">
<div class="card-dark" style="border-left:3px solid #555">
<div class="card-body" style="text-align:center;color:#555;padding:2rem">
<i class="bi bi-slash-circle" style="font-size:2rem"></i>
<div>${{esc(conn.name || 'Port ' + si)}} &mdash; Not Present</div>
<div>SFP-${{i}} &mdash; Not Present</div>
</div>
</div>
</div>`;
continue;
}}
const ddm = isTrue(info.diagCapable);
const ddm = info.diagCapable === '1';
const ddmBadge = ddm
? '<span class="badge bg-success">DDM Supported</span>'
: '<span class="badge bg-secondary">DDM Not Supported</span>';
@ -1385,10 +1249,10 @@ function renderSfp() {{
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}}">
cards += `<div class="sfp-detail ${{i===1?'active':''}}" id="sfp-detail-${{i}}">
<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)}}
<i class="bi bi-lightning-charge"></i> SFP-${{i}}: ${{esc(info.vendor)}} ${{esc(info.vendorPn)}}
<span class="ms-auto">${{ddmBadge}}</span>
</div>
<div class="card-body">
@ -1408,8 +1272,8 @@ function renderSfp() {{
<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>Internal Cal: <strong>${{info.internalCal==='1' ? 'Yes' : 'No'}}</strong></div>
<div>Alarm Capable: <strong>${{info.alarmCapable==='1' ? '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>
@ -1429,7 +1293,7 @@ function renderSfp() {{
<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('')}}
${{[1,2,3,4].map(i => `<button class="btn btn-sm btn-outline-secondary ms-1" onclick="selectSfp(${{i}})">SFP-${{i}}</button>`).join('')}}
</div>
${{cards}}
</div>
@ -1450,7 +1314,7 @@ function renderAlarms() {{
// Collect active alarms (when status table available)
const active = [];
for (const [k,a] of Object.entries(alarmStatus)) {{
if (isTrue(a.active)) {{
if (a.active === '1') {{
const cfg = cfgByNum[a.number] || {{}};
active.push({{ ...a, ...cfg, _statusId: k }});
}}
@ -1492,7 +1356,7 @@ function renderAlarms() {{
<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>`;
<td>${{c.enabled === '1' ? '<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>`;
@ -1639,12 +1503,12 @@ function renderFilters() {{
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));
if (f.macDstEn === '1') conditions.push('MAC Dst: ' + esc(f.macDst));
if (f.macSrcEn === '1') conditions.push('MAC Src: ' + esc(f.macSrc));
if (f.etypeEn === '1') conditions.push('EType: ' + esc(f.etype));
if (f.vlan1IdEn === '1') conditions.push('VLAN1: ' + esc(f.vlan1Id));
if (f.vlan2IdEn === '1') conditions.push('VLAN2: ' + esc(f.vlan2Id));
if (f.vlan1PriorEn === '1') conditions.push('PCP1: ' + esc(f.vlan1Prior));
const condStr = conditions.length ? conditions.join(', ') : '<span class="status-na">any (catchall)</span>';
rows += `<tr>
@ -1788,6 +1652,7 @@ function parseRemotePlatform(sysDesc, sysName) {{
function renderLldp() {{
const neighbors = DATA.lldp_neighbors || {{}};
const stats = DATA.lldp_stats || {{}};
const ifaces = DATA.interfaces || {{}};
const sfpInfo = DATA.sfp_info || {{}};
const device = DATA.device || {{}};
@ -1799,13 +1664,14 @@ function renderLldp() {{
const localName = device.identifier || device.sysName || 'NID';
const localModel = device.commercialName || device.sysDescr || 'NID';
// Determine slot state connector-type aware (same logic as renderPanel)
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);
const present = sfp && sfp.present === '1';
if (!present) return 'empty';
if (iface && isUp(iface.ifOperStatus)) return 'present-link';
return 'present-nolink';
@ -1816,99 +1682,137 @@ function renderLldp() {{
}}
}}
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>
</div>`;
}}
// Build vertical columns for data ports
const {{ dataPorts, mgmtPort }} = getPortLists();
let colsHtml = '';
for (const portKey of dataPorts) {{
// Build a row for each network port (1-4)
let rowsHtml = '';
for (let i = 1; i <= 4; i++) {{
const portKey = String(i);
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 slotName = conn.name || (isSfp ? `SFP-${{i}}` : `RJ45-${{i}}`);
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 platform = parseRemotePlatform(nbr.remSysDesc, nbr.remSysName);
const shortName = (nbr.remSysName || '').split('.')[0] || 'Unknown';
const modelLine = platform.vendor ? `${{platform.vendor}} ${{platform.model}}` : platform.model;
const linkClass = localUp ? 'up' : 'down';
colsHtml += `
<div class="lldp-col">
<div class="lldp-col-header ${{state}}">
rowsHtml += `
<div class="lldp-row">
<div class="lldp-local-slot ${{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 class="lldp-connector">
<div class="link-port-label">${{esc(iface.ifName || nbr.localPortName || 'Port ' + i)}}</div>
<div class="link-line ${{linkClass}}"></div>
<div class="link-port-label">${{esc(nbr.remPortId || '?')}}</div>
</div>
<div class="lldp-remote">
<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>
</div>
</div>`;
}} else {{
colsHtml += `
<div class="lldp-col idle">
<div class="lldp-col-header ${{state}}">
rowsHtml += `
<div class="lldp-row idle">
<div class="lldp-local-slot ${{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 class="lldp-connector">
<div class="link-line" style="background:#2d3340;height:2px;opacity:0.5"></div>
</div>
<div class="lldp-idle-label">No LLDP neighbor</div>
</div>`;
}}
}}
// MGMT column
const mgmtNbr = mgmtPort ? neighborByPort[mgmtPort] : null;
// Include MGMT port if it has a neighbor
const mgmtNbr = neighborByPort['5'];
if (mgmtNbr) {{
const mgmtIface = ifaces[mgmtPort] || {{}};
const mgmtIface = ifaces['5'] || {{}};
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'}}">
const platform = parseRemotePlatform(mgmtNbr.remSysDesc, mgmtNbr.remSysName);
const shortName = (mgmtNbr.remSysName || '').split('.')[0] || 'Unknown';
const modelLine = platform.vendor ? `${{platform.vendor}} ${{platform.model}}` : platform.model;
rowsHtml += `
<div class="lldp-row" style="margin-top:0.5rem;padding-top:0.5rem;border-top:1px dashed #3a3f4b">
<div class="lldp-local-slot ${{mgmtUp ? 'present-link' : 'present-nolink'}}" style="width:40px;font-size:0.55rem">
<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 class="lldp-connector">
<div class="link-port-label">Management</div>
<div class="link-line ${{mgmtUp ? 'up' : 'down'}}"></div>
<div class="link-port-label">${{esc(mgmtNbr.remPortId || '?')}}</div>
</div>
<div class="lldp-remote">
<div class="remote-hostname">${{esc(shortName)}}</div>
<div class="remote-model">${{esc(modelLine)}}</div>
<div class="remote-detail"><span class="rlabel">MAC</span> ${{esc(mgmtNbr.chassisId || '?')}}</div>
${{mgmtNbr.mgmtIPv4 ? `<div class="remote-mgmt"><i class="bi bi-globe2"></i> ${{esc(mgmtNbr.mgmtIPv4)}}</div>` : ''}}
</div>
</div>`;
}}
// Build per-port LLDP stats table
let statsRows = '';
for (const [port, s] of Object.entries(stats).sort((a,b) => parseInt(a[0]) - parseInt(b[0]))) {{
const tx = parseInt(s.txFrames || '0').toLocaleString();
const rx = parseInt(s.rxFrames || '0').toLocaleString();
const nb = s.neighborsLearned || '0';
const name = (ifaces[port] || {{}}).ifName || `Port ${{port}}`;
const hasActive = !!neighborByPort[port];
const activeMarker = hasActive ? '<span style="color:var(--green)"> (active)</span>' : '';
statsRows += `<tr>
<td>${{parseInt(port) <= 4 ? 'SFP-' + port : 'MGMT'}}</td>
<td>${{esc(name)}}</td>
<td style="text-align:right">${{tx}}</td>
<td style="text-align:right">${{rx}}</td>
<td style="text-align:center">${{nb}}${{activeMarker}}</td>
</tr>`;
}}
document.getElementById('sec-lldp').innerHTML = `
<div class="card-dark">
<div class="card-header collapsible"><i class="bi bi-diagram-3"></i> LLDP Topology<i class="bi bi-chevron-down collapse-chevron"></i></div>
<div class="card-body">
<div class="lldp-panel">
<span class="panel-label">${{esc(localName)}} &mdash; ${{esc(localModel)}}</span>
<div class="lldp-columns">
${{colsHtml}}
</div>
${{rowsHtml}}
</div>
${{Object.keys(stats).length ? `
<div style="margin-top:1rem">
<h6 style="font-size:0.8rem;color:var(--text-muted);margin-bottom:0.5rem">
<i class="bi bi-bar-chart"></i> Per-Port LLDP Statistics
</h6>
<table class="topo-stats-table">
<thead><tr>
<th>Port</th><th>Interface</th><th style="text-align:right">TX Frames</th>
<th style="text-align:right">RX Frames</th><th style="text-align:center">Neighbors</th>
</tr></thead>
<tbody>${{statsRows}}</tbody>
</table>
</div>
` : ''}}
</div>
</div>`;
}}
@ -2008,7 +1912,7 @@ function renderCoverage() {{
// Check SFP DDM
const sfpInfo = DATA.sfp_info || {{}};
for (const [k,s] of Object.entries(sfpInfo)) {{
if (isTrue(s.present) && !isTrue(s.diagCapable)) {{
if (s.present === '1' && s.diagCapable !== '1') {{
gaps.push({{
type: 'red',
text: `SFP-${{k}} DDM: Not available via SNMP (diagCapable=false). NID web UI reads SFP I2C bus directly.`
@ -2127,7 +2031,6 @@ function renderPortCmp() {{
// Render all sections
renderWalkControl();
renderHeader();
renderMap();
renderPanel();
renderLldp();
renderInterfaces();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View File

@ -41,6 +41,7 @@ TARGETED_OIDS = [
(".1.3.6.1.4.1.22420.1.1", "ACD-DESC-MIB"),
(".1.3.6.1.4.1.22420.2.1", "ACD-ALARM-MIB"),
(".1.3.6.1.4.1.22420.2.2", "ACD-FILTER-MIB"),
(".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB"),
(".1.3.6.1.4.1.22420.2.4", "ACD-SFP-MIB"),
(".1.3.6.1.4.1.22420.2.6", "ACD-REGULATOR-MIB"),
(".1.3.6.1.4.1.22420.2.8", "ACD-SMAP-MIB"),
@ -78,10 +79,6 @@ SNMP_V3_PRIV_PROTO = ENV.get("SNMP_V3_PRIV_PROTO", "AES")
SNMP_V3_PRIV_PASS = ENV.get("SNMP_V3_PRIV_PASS", "")
SNMP_V3_SEC_LEVEL = ENV.get("SNMP_V3_SEC_LEVEL", "authPriv")
# Conditionally include heavy policy MIB (~73% of all OIDs)
if ENV.get("SNMP_WALK_POLICIES", "true").lower() == "true":
TARGETED_OIDS.append((".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB"))
# ── Walk state (shared across threads) ───────────────────────────────
walk_lock = threading.Lock()
@ -128,7 +125,7 @@ def build_snmp_auth() -> list:
return ["-v", SNMP_VERSION, "-c", SNMP_COMMUNITY]
def run_walk(target: str, mode: str, policies: bool = True):
def run_walk(target: str, mode: str):
"""Execute the full walk pipeline in a background thread."""
global latest_json
@ -145,11 +142,6 @@ def run_walk(target: str, mode: str, policies: bool = True):
auth = build_snmp_auth()
t_start = time.time()
# Build OID list for this walk — optionally exclude heavy policy MIB
walk_oids = list(TARGETED_OIDS)
if not policies:
walk_oids = [(oid, lbl) for oid, lbl in walk_oids if lbl != "ACD-POLICY-MIB"]
try:
# ── Step 1: snmpwalk ──────────────────────────────────────
# Use snmpbulkwalk (GETBULK PDUs) when available — much faster
@ -165,7 +157,7 @@ def run_walk(target: str, mode: str, policies: bool = True):
walk_file.write_text(result.stdout)
else:
# Walk subtrees in parallel for speed
total = len(walk_oids)
total = len(TARGETED_OIDS)
completed = [0] # mutable counter for progress
results_map = {}
@ -183,7 +175,7 @@ def run_walk(target: str, mode: str, policies: bool = True):
with ThreadPoolExecutor(max_workers=4) as pool:
futures = [
pool.submit(walk_subtree, i, oid, label)
for i, (oid, label) in enumerate(walk_oids)
for i, (oid, label) in enumerate(TARGETED_OIDS)
]
for fut in as_completed(futures):
idx, output = fut.result()
@ -317,39 +309,11 @@ class NIDHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == "/api/walk":
self._handle_walk()
elif self.path == "/api/ping":
self._handle_ping()
elif self.path == "/api/clear":
self._handle_clear()
else:
self._send(404, "text/plain", b"Not found")
def _handle_ping(self):
"""Ping a target IP to check reachability."""
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
target = body.get("target", "").strip()
if not target:
self._send_json(400, {"error": "target IP required"})
return
ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
if not ip_re.match(target):
self._send_json(400, {"error": f"Invalid IP address: {target}"})
return
try:
result = subprocess.run(
["ping", "-c", "1", "-W", "2", target],
capture_output=True, text=True, timeout=5,
)
reachable = result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
reachable = False
self._send_json(200, {"reachable": reachable, "target": target})
def _handle_walk(self):
"""Start a walk in a background thread."""
current, _ = get_status()
@ -361,7 +325,6 @@ class NIDHandler(BaseHTTPRequestHandler):
body = json.loads(self.rfile.read(length)) if length else {}
target = body.get("target", "").strip()
mode = body.get("mode", "targeted").strip()
policies = body.get("policies", True)
if not target:
self._send_json(400, {"error": "target IP required"})
@ -371,7 +334,7 @@ class NIDHandler(BaseHTTPRequestHandler):
return
set_status("walking", message="Starting walk...", progress=1)
thread = threading.Thread(target=run_walk, args=(target, mode, policies), daemon=True)
thread = threading.Thread(target=run_walk, args=(target, mode), daemon=True)
thread.start()
self._send_json(200, {"status": "started", "target": target, "mode": mode})

View File

@ -75,8 +75,6 @@ fi
# ── Define OID subtrees ──────────────────────────────────────────────
# Targeted: only what the viewer/parser needs
WALK_POLICIES="${SNMP_WALK_POLICIES:-true}"
TARGETED_OIDS=(
.1.3.6.1.2.1.1 # System (sysDescr, sysName, sysUpTime, …)
.1.3.6.1.2.1.2 # IF-MIB (interface table)
@ -87,16 +85,13 @@ TARGETED_OIDS=(
.1.3.6.1.4.1.22420.1.1 # ACD-DESC-MIB (device identity, connectors, sensors)
.1.3.6.1.4.1.22420.2.1 # ACD-ALARM-MIB
.1.3.6.1.4.1.22420.2.2 # ACD-FILTER-MIB
.1.3.6.1.4.1.22420.2.3 # ACD-POLICY-MIB (L2 policies, stats)
.1.3.6.1.4.1.22420.2.4 # ACD-SFP-MIB (transceiver info/diag)
.1.3.6.1.4.1.22420.2.6 # ACD-REGULATOR-MIB
.1.3.6.1.4.1.22420.2.8 # ACD-SMAP-MIB (CoS profiles)
.1.3.6.1.4.1.22420.2.9 # ACD-PORT-MIB (port config/status)
)
if [[ "$WALK_POLICIES" == "true" ]]; then
TARGETED_OIDS+=(.1.3.6.1.4.1.22420.2.3) # ACD-POLICY-MIB (~73% of all OIDs)
fi
# ── Prepare output paths ─────────────────────────────────────────────
TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)"
SAFE_IP="${TARGET//./-}"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff