Compare commits

...

5 Commits

Author SHA1 Message Date
sam
bcb179e7e4 Fix TruthValue handling for different firmware versions
Some Accedian firmware reports SNMP TruthValue fields as '1'/'2'
(INTEGER) while others use 'true'/'false' (textual). Add isTrue()
helper that accepts both formats and replace all 15+ boolean checks
across the viewer (present, diagCapable, active, enabled, filter
enable flags, etc).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:30:36 -07:00
Sam Lewis
4daf26b778 Added walks folder 2026-03-06 08:22:22 -07:00
Sam Lewis
09a2db7373 added images 2026-03-06 08:02:33 -07:00
sam
df8c74627b Dynamic port rendering for multi-port NID devices
Replace hardcoded 4-port loops in renderPanel(), renderSfp(), and
renderLldp() with dynamic iteration over connectors data. Devices
like the AMO-10000-LT-S with more than 4 ports now render all
ports automatically. Management port detected by connector name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:45:34 -07:00
sam
d6bf394297 Add optional policy MIB toggle and pre-walk ping check
- Add SNMP_WALK_POLICIES env var and UI checkbox to skip ACD-POLICY-MIB
  (~73% of all OIDs), cutting walk time from ~25s to ~11s
- Add /api/ping endpoint with reachability check before walk starts
- Show "NID Management is UP" (green) or "NID is DOWN" (red) status
- Block walk if target is unreachable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:02:52 -07:00
34 changed files with 285086 additions and 264 deletions

View File

@ -22,5 +22,10 @@ SNMP_COMMUNITY=public
# "targeted" = walk only subtrees used by the viewer (faster) # "targeted" = walk only subtrees used by the viewer (faster)
SNMP_WALK_MODE=targeted 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 ──
SERVER_PORT=5525 SERVER_PORT=5525

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@ -41,7 +41,6 @@ TARGETED_OIDS = [
(".1.3.6.1.4.1.22420.1.1", "ACD-DESC-MIB"), (".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.1", "ACD-ALARM-MIB"),
(".1.3.6.1.4.1.22420.2.2", "ACD-FILTER-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.4", "ACD-SFP-MIB"),
(".1.3.6.1.4.1.22420.2.6", "ACD-REGULATOR-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"), (".1.3.6.1.4.1.22420.2.8", "ACD-SMAP-MIB"),
@ -79,6 +78,10 @@ SNMP_V3_PRIV_PROTO = ENV.get("SNMP_V3_PRIV_PROTO", "AES")
SNMP_V3_PRIV_PASS = ENV.get("SNMP_V3_PRIV_PASS", "") SNMP_V3_PRIV_PASS = ENV.get("SNMP_V3_PRIV_PASS", "")
SNMP_V3_SEC_LEVEL = ENV.get("SNMP_V3_SEC_LEVEL", "authPriv") 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 state (shared across threads) ───────────────────────────────
walk_lock = threading.Lock() walk_lock = threading.Lock()
@ -125,7 +128,7 @@ def build_snmp_auth() -> list:
return ["-v", SNMP_VERSION, "-c", SNMP_COMMUNITY] return ["-v", SNMP_VERSION, "-c", SNMP_COMMUNITY]
def run_walk(target: str, mode: str): def run_walk(target: str, mode: str, policies: bool = True):
"""Execute the full walk pipeline in a background thread.""" """Execute the full walk pipeline in a background thread."""
global latest_json global latest_json
@ -142,6 +145,11 @@ def run_walk(target: str, mode: str):
auth = build_snmp_auth() auth = build_snmp_auth()
t_start = time.time() 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: try:
# ── Step 1: snmpwalk ────────────────────────────────────── # ── Step 1: snmpwalk ──────────────────────────────────────
# Use snmpbulkwalk (GETBULK PDUs) when available — much faster # Use snmpbulkwalk (GETBULK PDUs) when available — much faster
@ -157,7 +165,7 @@ def run_walk(target: str, mode: str):
walk_file.write_text(result.stdout) walk_file.write_text(result.stdout)
else: else:
# Walk subtrees in parallel for speed # Walk subtrees in parallel for speed
total = len(TARGETED_OIDS) total = len(walk_oids)
completed = [0] # mutable counter for progress completed = [0] # mutable counter for progress
results_map = {} results_map = {}
@ -175,7 +183,7 @@ def run_walk(target: str, mode: str):
with ThreadPoolExecutor(max_workers=4) as pool: with ThreadPoolExecutor(max_workers=4) as pool:
futures = [ futures = [
pool.submit(walk_subtree, i, oid, label) pool.submit(walk_subtree, i, oid, label)
for i, (oid, label) in enumerate(TARGETED_OIDS) for i, (oid, label) in enumerate(walk_oids)
] ]
for fut in as_completed(futures): for fut in as_completed(futures):
idx, output = fut.result() idx, output = fut.result()
@ -309,11 +317,39 @@ class NIDHandler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
if self.path == "/api/walk": if self.path == "/api/walk":
self._handle_walk() self._handle_walk()
elif self.path == "/api/ping":
self._handle_ping()
elif self.path == "/api/clear": elif self.path == "/api/clear":
self._handle_clear() self._handle_clear()
else: else:
self._send(404, "text/plain", b"Not found") 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): def _handle_walk(self):
"""Start a walk in a background thread.""" """Start a walk in a background thread."""
current, _ = get_status() current, _ = get_status()
@ -325,6 +361,7 @@ class NIDHandler(BaseHTTPRequestHandler):
body = json.loads(self.rfile.read(length)) if length else {} body = json.loads(self.rfile.read(length)) if length else {}
target = body.get("target", "").strip() target = body.get("target", "").strip()
mode = body.get("mode", "targeted").strip() mode = body.get("mode", "targeted").strip()
policies = body.get("policies", True)
if not target: if not target:
self._send_json(400, {"error": "target IP required"}) self._send_json(400, {"error": "target IP required"})
@ -334,7 +371,7 @@ class NIDHandler(BaseHTTPRequestHandler):
return return
set_status("walking", message="Starting walk...", progress=1) set_status("walking", message="Starting walk...", progress=1)
thread = threading.Thread(target=run_walk, args=(target, mode), daemon=True) thread = threading.Thread(target=run_walk, args=(target, mode, policies), daemon=True)
thread.start() thread.start()
self._send_json(200, {"status": "started", "target": target, "mode": mode}) self._send_json(200, {"status": "started", "target": target, "mode": mode})

View File

@ -75,6 +75,8 @@ fi
# ── Define OID subtrees ────────────────────────────────────────────── # ── Define OID subtrees ──────────────────────────────────────────────
# Targeted: only what the viewer/parser needs # Targeted: only what the viewer/parser needs
WALK_POLICIES="${SNMP_WALK_POLICIES:-true}"
TARGETED_OIDS=( TARGETED_OIDS=(
.1.3.6.1.2.1.1 # System (sysDescr, sysName, sysUpTime, …) .1.3.6.1.2.1.1 # System (sysDescr, sysName, sysUpTime, …)
.1.3.6.1.2.1.2 # IF-MIB (interface table) .1.3.6.1.2.1.2 # IF-MIB (interface table)
@ -85,13 +87,16 @@ 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.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.1 # ACD-ALARM-MIB
.1.3.6.1.4.1.22420.2.2 # ACD-FILTER-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.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.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.8 # ACD-SMAP-MIB (CoS profiles)
.1.3.6.1.4.1.22420.2.9 # ACD-PORT-MIB (port config/status) .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 ───────────────────────────────────────────── # ── Prepare output paths ─────────────────────────────────────────────
TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)" TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)"
SAFE_IP="${TARGET//./-}" 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

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