Compare commits

..

No commits in common. "policy-tinkering" and "main" have entirely different histories.

4 changed files with 216 additions and 340 deletions

View File

@ -22,10 +22,5 @@ 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,8 +60,6 @@ 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;
@ -163,7 +161,7 @@ body {{
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.2rem;
flex-wrap: wrap; flex-wrap: wrap;
position: relative; position: relative;
}} }}
@ -212,8 +210,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;
@ -351,20 +349,6 @@ 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;
@ -475,21 +459,14 @@ body {{
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
}} }}
/* LLDP vertical columns */ .lldp-row {{
.lldp-columns {{
display: flex; display: flex;
gap: 1rem;
align-items: stretch;
}}
.lldp-col {{
flex: 1;
display: flex;
flex-direction: column;
align-items: center; align-items: center;
min-width: 0; gap: 0;
margin-bottom: 0.75rem;
}} }}
.lldp-col.idle {{ opacity: 0.4; }} .lldp-row:last-child {{ margin-bottom: 0; }}
.lldp-col-header {{ .lldp-local-slot {{
width: 56px; width: 56px;
height: 44px; height: 44px;
border-radius: 4px; border-radius: 4px;
@ -502,178 +479,147 @@ body {{
font-weight: 600; font-weight: 600;
flex-shrink: 0; flex-shrink: 0;
}} }}
.lldp-col-header.present-link {{ .lldp-local-slot.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-col-header.present-nolink {{ .lldp-local-slot.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-col-header.empty {{ .lldp-local-slot.empty {{
background: #1a1d24; background: #1a1d24;
border-color: #2d3340; border-color: #2d3340;
color: #555; color: #555;
}} }}
.lldp-col-header .slot-label {{ .lldp-local-slot .slot-label {{
font-size: 0.6rem; font-size: 0.6rem;
color: var(--text-muted); color: var(--text-muted);
}} }}
.lldp-col-port-label {{ .lldp-connector {{
font-size: 0.6rem; display: flex;
color: var(--text-muted); flex-direction: column;
text-align: center; align-items: center;
font-family: 'JetBrains Mono', monospace; justify-content: center;
margin: 0.3rem 0 0.15rem; min-width: 80px;
white-space: nowrap; max-width: 200px;
overflow: hidden; flex: 1;
text-overflow: ellipsis; padding: 0 0.4rem;
max-width: 100%;
}} }}
.lldp-col-line {{ .lldp-connector .link-line {{
width: 3px; width: 100%;
height: 36px; height: 3px;
border-radius: 2px; border-radius: 2px;
margin: 0.15rem 0;
position: relative; position: relative;
}} }}
.lldp-col-line.up {{ .lldp-connector .link-line.up {{
background: var(--green); background: var(--green);
box-shadow: 0 0 8px rgba(34,197,94,0.3); box-shadow: 0 0 8px rgba(34,197,94,0.3);
}} }}
.lldp-col-line.down {{ .lldp-connector .link-line.down {{
background: var(--amber); background: var(--amber);
box-shadow: 0 0 8px rgba(245,158,11,0.3); box-shadow: 0 0 8px rgba(245,158,11,0.3);
}} }}
.lldp-col-line.idle {{ .lldp-connector .link-line::before,
background: #2d3340; .lldp-connector .link-line::after {{
height: 24px;
opacity: 0.5;
}}
.lldp-col-line::before,
.lldp-col-line::after {{
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; top: 50%;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
transform: translateX(-50%); transform: translateY(-50%);
}} }}
.lldp-col-line.up::before, .lldp-connector .link-line.up::before,
.lldp-col-line.up::after {{ background: var(--green); }} .lldp-connector .link-line.up::after {{ background: var(--green); }}
.lldp-col-line.down::before, .lldp-connector .link-line.down::before,
.lldp-col-line.down::after {{ background: var(--amber); }} .lldp-connector .link-line.down::after {{ background: var(--amber); }}
.lldp-col-line::before {{ top: -4px; }} .lldp-connector .link-line::before {{ left: -4px; }}
.lldp-col-line::after {{ bottom: -4px; }} .lldp-connector .link-line::after {{ right: -4px; }}
.lldp-col-line.idle::before, .lldp-connector .link-port-label {{
.lldp-col-line.idle::after {{ display: none; }} font-size: 0.6rem;
.lldp-col-remote {{ color: var(--text-muted);
text-align: center;
white-space: nowrap;
font-family: 'JetBrains Mono', monospace;
margin: 0.2rem 0;
}}
.lldp-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;
width: 100%; min-width: 200px;
margin-top: 0.15rem; max-width: 320px;
flex: 1; flex-shrink: 0;
}} }}
.lldp-col-remote .remote-hostname {{ .lldp-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-col-remote .remote-model {{ .lldp-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-col-remote .remote-detail {{ .lldp-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-col-remote .remote-detail .rlabel {{ .lldp-remote .remote-detail .rlabel {{
color: #555; color: #555;
display: inline-block; display: inline-block;
min-width: 32px; min-width: 32px;
}} }}
.lldp-col-remote .remote-mgmt {{ .lldp-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-col-idle-label {{ .lldp-row.idle {{
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;
}} }}
.lldp-col-divider {{ .topo-stats-table {{
width: 1px; width: 100%;
align-self: stretch; margin-top: 1rem;
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);
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; 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:1800px"> <div class="container-fluid py-3" style="max-width:1400px">
<!-- 0. WALK CONTROL --> <!-- 0. WALK CONTROL -->
<div id="sec-walk"></div> <div id="sec-walk"></div>
<!-- 1. DEVICE HEADER + MAP --> <!-- 1. DEVICE HEADER -->
<div style="display:flex;gap:1rem;align-items:stretch"> <div id="sec-header"></div>
<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>
@ -684,20 +630,20 @@ body {{
<!-- 4. INTERFACES TABLE --> <!-- 4. INTERFACES TABLE -->
<div id="sec-interfaces"></div> <div id="sec-interfaces"></div>
<!-- 56. SFP + ALARMS (side by side) --> <!-- 5. SFP CARDS -->
<div style="display:flex;gap:1rem;align-items:stretch"> <div id="sec-sfp"></div>
<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> <!-- 6. ALARMS -->
</div> <div id="sec-alarms"></div>
<!-- 7. TRAFFIC POLICIES --> <!-- 7. TRAFFIC POLICIES -->
<div id="sec-policies"></div> <div id="sec-policies"></div>
<!-- 89. L2 FILTERS + REGULATORS (side by side) --> <!-- 8. L2 FILTERS -->
<div style="display:flex;gap:1rem;align-items:stretch"> <div id="sec-filters"></div>
<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> <!-- 9. REGULATORS -->
</div> <div id="sec-regulators"></div>
<!-- 10. COVERAGE MATRIX --> <!-- 10. COVERAGE MATRIX -->
<div id="sec-coverage"></div> <div id="sec-coverage"></div>
@ -829,10 +775,6 @@ 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>
@ -853,7 +795,6 @@ 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) {{
@ -862,26 +803,6 @@ function startWalk() {{
}} }}
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Pinging...';
updateWalkStatus('running', 'Checking reachability...');
// Step 1: Ping check
fetch('/api/ping', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ target }})
}})
.then(r => r.json())
.then(ping => {{
if (!ping.reachable) {{
updateWalkStatus('error', 'NID is DOWN. Verify Local Power and Router Interface Status.');
resetWalkBtn();
return;
}}
updateWalkStatus('complete', 'NID Management is UP');
// Step 2: Proceed with walk after brief pause to show UP status
setTimeout(() => {{
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Walking...'; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Walking...';
updateWalkStatus('running', 'Starting walk...'); updateWalkStatus('running', 'Starting walk...');
@ -891,7 +812,7 @@ function startWalk() {{
fetch('/api/walk', {{ fetch('/api/walk', {{
method: 'POST', method: 'POST',
headers: {{ 'Content-Type': 'application/json' }}, headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ target, mode, policies }}) body: JSON.stringify({{ target, mode }})
}}) }})
.then(r => r.json()) .then(r => r.json())
.then(resp => {{ .then(resp => {{
@ -911,6 +832,7 @@ function startWalk() {{
updateWalkStatus('complete', s.message); updateWalkStatus('complete', s.message);
walkEventSource.close(); walkEventSource.close();
walkEventSource = null; walkEventSource = null;
// Reload to pick up fresh data
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 800);
}} else if (s.state === 'error') {{ }} else if (s.state === 'error') {{
updateWalkStatus('error', s.message); updateWalkStatus('error', s.message);
@ -924,18 +846,14 @@ function startWalk() {{
walkEventSource.onerror = () => {{ walkEventSource.onerror = () => {{
walkEventSource.close(); walkEventSource.close();
walkEventSource = null; 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', 'Failed to connect: ' + err.message);
resetWalkBtn(); resetWalkBtn();
}}); }});
}}, 600);
}})
.catch(err => {{
updateWalkStatus('error', 'Ping check failed: ' + err.message);
resetWalkBtn();
}});
}} }}
function updateWalkStatus(state, message) {{ function updateWalkStatus(state, message) {{
@ -1065,40 +983,6 @@ 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 || {{}};
@ -1768,6 +1652,7 @@ 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 || {{}};
@ -1779,6 +1664,7 @@ 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';
@ -1796,30 +1682,8 @@ function renderLldp() {{
}} }}
}} }}
function buildNeighborCard(nbr, cssClass) {{ // Build a row for each network port (1-4)
const platform = parseRemotePlatform(nbr.remSysDesc, nbr.remSysName); let rowsHtml = '';
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 ports 1-4
let colsHtml = '';
for (let i = 1; i <= 4; i++) {{ for (let i = 1; i <= 4; i++) {{
const portKey = String(i); const portKey = String(i);
const conn = connectors[portKey] || {{}}; const conn = connectors[portKey] || {{}};
@ -1834,61 +1698,121 @@ function renderLldp() {{
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 ' + i;
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 += `
<div class="lldp-col"> rowsHtml += `
<div class="lldp-col-header ${{state}}"> <div class="lldp-row">
<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-col-port-label">${{esc(portLabel)}}</div> <div class="lldp-connector">
<div class="lldp-col-line ${{linkClass}}"></div> <div class="link-port-label">${{esc(iface.ifName || nbr.localPortName || 'Port ' + i)}}</div>
<div class="lldp-col-port-label">${{esc(nbr.remPortId || '?')}}</div> <div class="link-line ${{linkClass}}"></div>
${{buildNeighborCard(nbr, 'lldp-col-remote')}} <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>`; </div>`;
}} else {{ }} else {{
colsHtml += ` rowsHtml += `
<div class="lldp-col idle"> <div class="lldp-row idle">
<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-col-port-label">${{esc(portLabel)}}</div> <div class="lldp-connector">
<div class="lldp-col-line idle"></div> <div class="link-line" style="background:#2d3340;height:2px;opacity:0.5"></div>
<div class="lldp-col-idle-label">No LLDP neighbor</div> </div>
<div class="lldp-idle-label">No LLDP neighbor</div>
</div>`; </div>`;
}} }}
}} }}
// MGMT column (port 5) // Include MGMT port if it has a neighbor
const mgmtNbr = neighborByPort['5']; const mgmtNbr = neighborByPort['5'];
if (mgmtNbr) {{ if (mgmtNbr) {{
const mgmtIface = ifaces['5'] || {{}}; const mgmtIface = ifaces['5'] || {{}};
const mgmtUp = isUp(mgmtIface.ifOperStatus); const mgmtUp = isUp(mgmtIface.ifOperStatus);
colsHtml += `<div class="lldp-col-divider"></div>`; const platform = parseRemotePlatform(mgmtNbr.remSysDesc, mgmtNbr.remSysName);
colsHtml += ` const shortName = (mgmtNbr.remSysName || '').split('.')[0] || 'Unknown';
<div class="lldp-col"> const modelLine = platform.vendor ? `${{platform.vendor}} ${{platform.model}}` : platform.model;
<div class="lldp-col-header ${{mgmtUp ? 'present-link' : 'present-nolink'}}"> 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> <i class="bi bi-ethernet"></i><span class="slot-label">MGMT</span>
</div> </div>
<div class="lldp-col-port-label">Management</div> <div class="lldp-connector">
<div class="lldp-col-line ${{mgmtUp ? 'up' : 'down'}}"></div> <div class="link-port-label">Management</div>
<div class="lldp-col-port-label">${{esc(mgmtNbr.remPortId || '?')}}</div> <div class="link-line ${{mgmtUp ? 'up' : 'down'}}"></div>
${{buildNeighborCard(mgmtNbr, 'lldp-col-remote')}} <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>`; </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>
<div class="lldp-columns"> ${{rowsHtml}}
${{colsHtml}}
</div> </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> </div>
</div>`; </div>`;
}} }}
@ -2107,7 +2031,6 @@ function renderPortCmp() {{
// Render all sections // Render all sections
renderWalkControl(); renderWalkControl();
renderHeader(); renderHeader();
renderMap();
renderPanel(); renderPanel();
renderLldp(); renderLldp();
renderInterfaces(); renderInterfaces();

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.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"),
@ -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_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()
@ -128,7 +125,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, policies: bool = True): def run_walk(target: str, mode: str):
"""Execute the full walk pipeline in a background thread.""" """Execute the full walk pipeline in a background thread."""
global latest_json global latest_json
@ -145,11 +142,6 @@ def run_walk(target: str, mode: str, policies: bool = True):
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
@ -165,7 +157,7 @@ def run_walk(target: str, mode: str, policies: bool = True):
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(walk_oids) total = len(TARGETED_OIDS)
completed = [0] # mutable counter for progress completed = [0] # mutable counter for progress
results_map = {} results_map = {}
@ -183,7 +175,7 @@ def run_walk(target: str, mode: str, policies: bool = True):
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(walk_oids) for i, (oid, label) in enumerate(TARGETED_OIDS)
] ]
for fut in as_completed(futures): for fut in as_completed(futures):
idx, output = fut.result() idx, output = fut.result()
@ -317,39 +309,11 @@ 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()
@ -361,7 +325,6 @@ 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"})
@ -371,7 +334,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, policies), daemon=True) thread = threading.Thread(target=run_walk, args=(target, mode), 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,8 +75,6 @@ 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)
@ -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.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//./-}"