diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ffd030f --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# ── SNMP NID Viewer – Environment Configuration ── +# Copy to .env and edit as needed. This file is gitignored. + +# Target device IP (override via CLI: ./snmp-walk.sh 10.0.0.1) +SNMP_TARGET=10.13.60.102 + +# ── SNMPv2c ── +SNMP_VERSION=2c +SNMP_COMMUNITY=public + +# ── SNMPv3 (future – uncomment and set when needed) ── +# SNMP_VERSION=3 +# SNMP_V3_USER= +# SNMP_V3_AUTH_PROTO=SHA # MD5 | SHA | SHA-256 | SHA-512 +# SNMP_V3_AUTH_PASS= +# SNMP_V3_PRIV_PROTO=AES # DES | AES | AES-256 +# SNMP_V3_PRIV_PASS= +# SNMP_V3_SEC_LEVEL=authPriv # noAuthNoPriv | authNoPriv | authPriv + +# ── Walk mode ── +# "full" = walk entire .1 tree (captures everything, ~27% larger) +# "targeted" = walk only subtrees used by the viewer (faster) +SNMP_WALK_MODE=targeted + +# ── Server ── +SERVER_PORT=5525 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71a09f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Environment secrets +.env + +# Python +__pycache__/ + +# Walk data (device-specific, generated) +walks/ diff --git a/build_nid_viewer.py b/build_nid_viewer.py index 75bfbf2..1e76e8d 100644 --- a/build_nid_viewer.py +++ b/build_nid_viewer.py @@ -76,6 +76,7 @@ def build_html(data: dict) -> str: --major: #fd7e14; --minor: #ffc107; --info-sev: #17a2b8; + --cyan: #06b6d4; }} body {{ background: var(--bg-dark); @@ -100,7 +101,24 @@ body {{ gap: 0.5rem; border-radius: 8px 8px 0 0; }} +.card-dark .card-header.collapsible {{ + cursor: pointer; + user-select: none; +}} +.card-dark .card-header.collapsible:hover {{ + background: #282c36; +}} +.card-dark .card-header .collapse-chevron {{ + margin-left: auto; + transition: transform 0.2s; + font-size: 0.8rem; + color: var(--text-muted); +}} +.card-dark .card-header.collapsed .collapse-chevron {{ + transform: rotate(-90deg); +}} .card-dark .card-body {{ padding: 1rem; }} +.card-dark .card-body.collapsed {{ display: none; }} .table-dark-custom {{ --bs-table-bg: transparent; --bs-table-color: var(--text-main); @@ -179,6 +197,22 @@ body {{ .sfp-slot.empty {{ background: #1a1d24; border-color: #2d3340; color: #555; }} .sfp-slot.selected {{ box-shadow: 0 0 0 2px var(--accent); }} .sfp-slot .slot-label {{ font-size: 0.6rem; color: var(--text-muted); }} +.sfp-slot-group {{ + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +}} +.port-label {{ + font-size: 0.55rem; + color: var(--text-muted); + text-align: center; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; +}} .mgmt-port {{ width: 40px; height: 44px; @@ -278,6 +312,100 @@ body {{ border-left-color: var(--amber); }} +/* Walk control card */ +.walk-controls {{ + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +}} +.walk-controls label {{ + font-size: 0.8rem; + color: var(--text-muted); + font-weight: 500; + white-space: nowrap; +}} +.walk-input {{ + background: var(--bg-dark); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-main); + padding: 0.35rem 0.6rem; + font-size: 0.85rem; + font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; + width: 160px; +}} +.walk-input:focus {{ + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(13,110,253,0.25); +}} +.walk-select {{ + background: var(--bg-dark); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-main); + padding: 0.35rem 0.6rem; + font-size: 0.85rem; +}} +.walk-select:focus {{ outline: none; border-color: var(--accent); }} +.walk-btn {{ + background: var(--accent); + border: none; + border-radius: 4px; + color: #fff; + padding: 0.35rem 1rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +}} +.walk-btn:hover {{ background: #0b5ed7; }} +.walk-btn:disabled {{ + background: #2d3340; + color: #555; + cursor: not-allowed; +}} +.walk-btn-clear {{ + background: #3a3a4a; +}} +.walk-btn-clear:hover {{ background: var(--red); }} +.walk-status {{ + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.82rem; + margin-top: 0.5rem; +}} +.walk-dot {{ + width: 8px; height: 8px; border-radius: 50%; + display: inline-block; + flex-shrink: 0; +}} +.walk-dot.idle {{ background: #555; }} +.walk-dot.running {{ background: var(--amber); animation: walk-pulse 1s ease-in-out infinite; }} +.walk-dot.complete {{ background: var(--green); }} +.walk-dot.error {{ background: var(--red); }} +@keyframes walk-pulse {{ + 0%, 100% {{ opacity: 1; }} + 50% {{ opacity: 0.4; }} +}} +.walk-progress {{ + height: 3px; + background: var(--border-color); + border-radius: 2px; + overflow: hidden; + margin-top: -1px; +}} +.walk-progress-fill {{ + height: 100%; + background: var(--accent); + border-radius: 2px; + width: 0%; + transition: width 0.3s ease; +}} + /* SFP detail card */ .sfp-detail {{ display: none; }} .sfp-detail.active {{ display: block; }} @@ -312,79 +440,90 @@ body {{ .tbl-scroll::-webkit-scrollbar-track {{ background: var(--bg-card); }} .tbl-scroll::-webkit-scrollbar-thumb {{ background: var(--border-color); border-radius: 3px; }} -/* LLDP topology diagram */ -.topo-container {{ - display: flex; - align-items: stretch; - gap: 0; - overflow-x: auto; - padding: 1rem 0; +/* LLDP topology diagram (redesigned) */ +.lldp-panel {{ + background: #16181f; + border: 2px solid #3a3f4b; + border-radius: 6px; + padding: 1.2rem 1.5rem; + position: relative; }} -.topo-device {{ +.lldp-panel .panel-label {{ + position: absolute; + top: -10px; + left: 12px; background: var(--bg-card); - border: 2px solid var(--border-color); - border-radius: 8px; - padding: 1rem 1.2rem; - min-width: 220px; - max-width: 300px; - flex-shrink: 0; -}} -.topo-device.local {{ - border-color: var(--accent); -}} -.topo-device.remote {{ - border-color: var(--cyan); -}} -.topo-device .topo-hostname {{ - font-size: 1rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 0.3rem; - word-break: break-all; -}} -.topo-device .topo-model {{ - font-size: 0.8rem; - color: var(--cyan); - margin-bottom: 0.5rem; -}} -.topo-device .topo-detail {{ - font-size: 0.75rem; + padding: 0 6px; + font-size: 0.7rem; color: var(--text-muted); - margin: 0.15rem 0; + text-transform: uppercase; + letter-spacing: 0.05em; }} -.topo-device .topo-detail .label {{ - color: #666; - min-width: 40px; - display: inline-block; +.lldp-row {{ + display: flex; + align-items: center; + gap: 0; + margin-bottom: 0.75rem; }} -.topo-device .topo-mgmt {{ - font-size: 0.8rem; - color: var(--green); - font-weight: 600; - margin-top: 0.4rem; - font-family: 'JetBrains Mono', monospace; -}} -.topo-link {{ +.lldp-row:last-child {{ margin-bottom: 0; }} +.lldp-local-slot {{ + width: 56px; + height: 44px; + border-radius: 4px; + border: 2px solid #3a3f4b; display: flex; flex-direction: column; align-items: center; justify-content: center; - min-width: 120px; - max-width: 200px; + font-size: 0.65rem; + font-weight: 600; flex-shrink: 0; - position: relative; - padding: 0 0.5rem; }} -.topo-link .link-line {{ +.lldp-local-slot.present-link {{ + background: rgba(34,197,94,0.15); + border-color: var(--green); + color: var(--green); +}} +.lldp-local-slot.present-nolink {{ + background: rgba(245,158,11,0.15); + border-color: var(--amber); + color: var(--amber); +}} +.lldp-local-slot.empty {{ + background: #1a1d24; + border-color: #2d3340; + color: #555; +}} +.lldp-local-slot .slot-label {{ + font-size: 0.6rem; + color: var(--text-muted); +}} +.lldp-connector {{ + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 80px; + max-width: 200px; + flex: 1; + padding: 0 0.4rem; +}} +.lldp-connector .link-line {{ width: 100%; height: 3px; border-radius: 2px; position: relative; }} -.topo-link .link-line.up {{ background: var(--green); box-shadow: 0 0 8px rgba(34,197,94,0.3); }} -.topo-link .link-line.down {{ background: var(--amber); box-shadow: 0 0 8px rgba(245,158,11,0.3); }} -.topo-link .link-line::before, -.topo-link .link-line::after {{ +.lldp-connector .link-line.up {{ + background: var(--green); + box-shadow: 0 0 8px rgba(34,197,94,0.3); +}} +.lldp-connector .link-line.down {{ + background: var(--amber); + box-shadow: 0 0 8px rgba(245,158,11,0.3); +}} +.lldp-connector .link-line::before, +.lldp-connector .link-line::after {{ content: ''; position: absolute; top: 50%; @@ -393,41 +532,69 @@ body {{ border-radius: 50%; transform: translateY(-50%); }} -.topo-link .link-line.up::before, -.topo-link .link-line.up::after {{ background: var(--green); }} -.topo-link .link-line.down::before, -.topo-link .link-line.down::after {{ background: var(--amber); }} -.topo-link .link-line::before {{ left: -4px; }} -.topo-link .link-line::after {{ right: -4px; }} -.topo-link .link-label {{ - font-size: 0.65rem; +.lldp-connector .link-line.up::before, +.lldp-connector .link-line.up::after {{ background: var(--green); }} +.lldp-connector .link-line.down::before, +.lldp-connector .link-line.down::after {{ background: var(--amber); }} +.lldp-connector .link-line::before {{ left: -4px; }} +.lldp-connector .link-line::after {{ right: -4px; }} +.lldp-connector .link-port-label {{ + font-size: 0.6rem; color: var(--text-muted); text-align: center; white-space: nowrap; - margin: 0.3rem 0; font-family: 'JetBrains Mono', monospace; + margin: 0.2rem 0; }} -.topo-port-list {{ - list-style: none; - padding: 0; - margin: 0.5rem 0 0 0; - font-size: 0.75rem; -}} -.topo-port-list li {{ - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.1rem 0; -}} -.topo-port-list .dot {{ - width: 8px; - height: 8px; - border-radius: 50%; +.lldp-remote {{ + background: #1e2128; + border: 1px solid #3a3f4b; + border-radius: 4px; + padding: 0.5rem 0.75rem; + min-width: 200px; + max-width: 320px; flex-shrink: 0; }} -.topo-port-list .dot.up {{ background: var(--green); }} -.topo-port-list .dot.down {{ background: #555; }} -.topo-port-list .dot.linked {{ background: var(--green); box-shadow: 0 0 4px rgba(34,197,94,0.5); }} +.lldp-remote .remote-hostname {{ + font-size: 0.85rem; + font-weight: 700; + color: var(--text-main); + word-break: break-all; +}} +.lldp-remote .remote-model {{ + font-size: 0.72rem; + color: var(--cyan); + margin-bottom: 0.3rem; +}} +.lldp-remote .remote-detail {{ + font-size: 0.7rem; + color: var(--text-muted); + margin: 0.1rem 0; + font-family: 'JetBrains Mono', monospace; +}} +.lldp-remote .remote-detail .rlabel {{ + color: #555; + display: inline-block; + min-width: 32px; +}} +.lldp-remote .remote-mgmt {{ + font-size: 0.75rem; + color: var(--green); + font-weight: 600; + margin-top: 0.3rem; + font-family: 'JetBrains Mono', monospace; +}} +.lldp-row.idle {{ + opacity: 0.4; +}} +.lldp-row.idle .lldp-connector {{ + min-width: 40px; +}} +.lldp-idle-label {{ + font-size: 0.7rem; + color: #555; + font-style: italic; +}} .topo-stats-table {{ width: 100%; margin-top: 1rem; @@ -448,33 +615,36 @@ body {{
+ +
+
- + +
+ +
- +
- +
- +
- +
- +
- -
-
@@ -543,6 +713,174 @@ function isPopulated(v) {{ return true; }} +// SNMP status helpers — handles both text ("up") and numeric ("1") values +function isUp(v) {{ return v === 'up' || v === '1'; }} +function isDown(v) {{ return v === 'down' || v === '2'; }} +function statusLabel(v) {{ + const map = {{'1':'up','2':'down','3':'testing','4':'unknown','5':'dormant','6':'notPresent','7':'lowerLayerDown'}}; + return map[v] || v || '?'; +}} + +// SFF-8024 connector type lookup (Table 4-3) +function sffConnectorType(v) {{ + const map = {{ + '1':'SC', '2':'FC Style 1', '3':'FC Style 2', '5':'MT-RJ', '6':'MU', + '7':'LC', '8':'LC', '9':'MTP/MPO 1x12', '10':'MTP/MPO 2x16', + '11':'SG', '12':'Optical pigtail', '13':'MTP/MPO 1x16', + '32':'HSSDC II', '33':'Copper pigtail', '34':'RJ45', '35':'No separable connector', + '36':'MXC 2x16', '37':'CS optical', '38':'SN optical', '39':'MTP/MPO 2x12', + '40':'MTP/MPO 1x16' + }}; + if (!v || v === '0') return 'Unknown'; + return map[v] || (parseInt(v) >= 128 ? 'Vendor specific' : `Code ${{v}}`); +}} + +// ── Card collapse toggle ───────────────────────────── +function toggleCard(header) {{ + header.classList.toggle('collapsed'); + const body = header.nextElementSibling; + if (body && body.classList.contains('card-body')) {{ + body.classList.toggle('collapsed'); + }} +}} +// Attach listeners after all cards are rendered +function initCollapsible() {{ + document.querySelectorAll('.card-header.collapsible').forEach(h => {{ + h.addEventListener('click', () => toggleCard(h)); + }}); +}} + +// ── 0. Walk Control ────────────────────────────────── +function renderWalkControl() {{ + // Try to extract a default IP from existing data + const ipAddrs = DATA.ip_addresses || {{}}; + const ipList = Object.values(ipAddrs).filter(ip => ip.address && ip.address !== '127.0.0.1'); + const defaultIp = ipList.length > 0 ? ipList[0].address : ''; + + document.getElementById('sec-walk').innerHTML = ` +
+
+ SNMP Walk Control + +
+
+
+
+ + + + + + +
+
+ + Idle +
+
+
`; +}} + +let walkEventSource = null; + +function startWalk() {{ + const target = document.getElementById('walk-target').value.trim(); + const mode = document.getElementById('walk-mode').value; + const btn = document.getElementById('walk-btn'); + + if (!target) {{ + updateWalkStatus('error', 'Enter a target IP address'); + return; + }} + + btn.disabled = true; + btn.innerHTML = ' Walking...'; + updateWalkStatus('running', 'Starting walk...'); + + // Close any previous SSE connection + if (walkEventSource) walkEventSource.close(); + + fetch('/api/walk', {{ + method: 'POST', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ target, mode }}) + }}) + .then(r => r.json()) + .then(resp => {{ + if (resp.error) {{ + updateWalkStatus('error', resp.error); + resetWalkBtn(); + return; + }} + // Open SSE for status updates + walkEventSource = new EventSource('/api/status'); + walkEventSource.onmessage = (e) => {{ + const s = JSON.parse(e.data); + const pct = s.progress || 0; + document.getElementById('walk-progress-fill').style.width = pct + '%'; + + if (s.state === 'complete') {{ + updateWalkStatus('complete', s.message); + walkEventSource.close(); + walkEventSource = null; + // Reload to pick up fresh data + setTimeout(() => window.location.reload(), 800); + }} else if (s.state === 'error') {{ + updateWalkStatus('error', s.message); + walkEventSource.close(); + walkEventSource = null; + resetWalkBtn(); + }} else {{ + updateWalkStatus('running', s.message); + }} + }}; + walkEventSource.onerror = () => {{ + walkEventSource.close(); + walkEventSource = null; + // SSE closed — could be server-side close after complete/error + // Check if we already handled it; if not, it was an unexpected close + }}; + }}) + .catch(err => {{ + updateWalkStatus('error', 'Failed to connect: ' + err.message); + resetWalkBtn(); + }}); +}} + +function updateWalkStatus(state, message) {{ + const el = document.getElementById('walk-status'); + if (!el) return; + const dotClass = state === 'running' ? 'running' : state === 'complete' ? 'complete' : state === 'error' ? 'error' : 'idle'; + const color = state === 'error' ? 'var(--red)' : state === 'complete' ? 'var(--green)' : ''; + el.innerHTML = `${{esc(message)}}`; +}} + +function resetWalkBtn() {{ + const btn = document.getElementById('walk-btn'); + if (btn) {{ + btn.disabled = false; + btn.innerHTML = ' Walk'; + }} + document.getElementById('walk-progress-fill').style.width = '0%'; +}} + +function clearData() {{ + if (!confirm('Clear all walk data and reload?')) return; + fetch('/api/clear', {{ method: 'POST' }}) + .then(r => r.json()) + .then(() => window.location.reload()) + .catch(err => updateWalkStatus('error', 'Clear failed: ' + err.message)); +}} + // ── 1. Device Header ───────────────────────────────── function renderHeader() {{ const d = DATA.device || {{}}; @@ -560,26 +898,50 @@ function renderHeader() {{ }} }} + const hasAlarmStatus = Object.keys(alarmStatus).length > 0; let alarmBadge = ''; - if (activeCount > 0) {{ - const parts = []; - if (sevCounts[3]) parts.push(`${{sevCounts[3]}} CRIT`); - if (sevCounts[2]) parts.push(`${{sevCounts[2]}} MAJ`); - if (sevCounts[1]) parts.push(`${{sevCounts[1]}} MIN`); - if (sevCounts[0]) parts.push(`${{sevCounts[0]}} INFO`); - alarmBadge = `${{activeCount}} Active Alarms ${{parts.join(' ')}}`; + if (hasAlarmStatus) {{ + if (activeCount > 0) {{ + const parts = []; + if (sevCounts[3]) parts.push(`${{sevCounts[3]}} CRIT`); + if (sevCounts[2]) parts.push(`${{sevCounts[2]}} MAJ`); + if (sevCounts[1]) parts.push(`${{sevCounts[1]}} MIN`); + if (sevCounts[0]) parts.push(`${{sevCounts[0]}} INFO`); + alarmBadge = `${{activeCount}} Active Alarms ${{parts.join(' ')}}`; + }} else {{ + alarmBadge = 'No Active Alarms'; + }} + }} else if (Object.keys(alarmCfg).length > 0) {{ + alarmBadge = `${{Object.keys(alarmCfg).length}} Alarm Defs`; + }} + + // Extract management IP from ip_addresses data + const ipAddrs = DATA.ip_addresses || {{}}; + const ifaces = DATA.interfaces || {{}}; + let mgmtIpHtml = ''; + const ipList = Object.values(ipAddrs).filter(ip => ip.address && ip.address !== '127.0.0.1'); + if (ipList.length > 0) {{ + const ipItems = ipList.map(ip => {{ + const ifEntry = ifaces[ip.ifIndex] || {{}}; + // Only show interface name if it's a real (non-synthetic) interface + const ifName = ifEntry.synthetic !== '1' ? (ifEntry.ifName || ifEntry.ifDescr || '') : ''; + const suffix = ifName ? ` on ${{esc(ifName)}}` : ''; + return `${{esc(ip.address)}}/${{esc(ip.prefixLength)}}${{suffix}}`; + }}); + mgmtIpHtml = ipItems.join('
'); }} else {{ - alarmBadge = 'No Active Alarms'; + mgmtIpHtml = 'Not available'; }} document.getElementById('sec-header').innerHTML = `
-
+
${{esc(d.commercialName || d.sysDescr || 'Accedian NID')}} - + SNMP Walk Visualization +
@@ -587,6 +949,7 @@ function renderHeader() {{
Hostname
${{esc(d.sysName)}}
Identifier
${{esc(d.identifier)}}
+
Mgmt IP
${{mgmtIpHtml}}
Serial
${{esc(d.serialNumber)}}
Firmware
${{esc(d.firmwareVersion)}}
Hardware
${{esc(d.hardwareVersion)}}
@@ -610,7 +973,7 @@ function renderHeader() {{
${{alarmBadge}}
- ${{Object.keys(alarmStatus).length}} alarm definitions
+ ${{hasAlarmStatus ? Object.keys(alarmStatus).length + ' alarm status entries' : Object.keys(alarmCfg).length + ' alarm definitions'}}
${{Object.keys(DATA.interfaces||{{}}).length}} interfaces
${{Object.keys(DATA.connectors||{{}}).length}} connectors
@@ -626,18 +989,31 @@ function renderPanel() {{ const sfpInfo = DATA.sfp_info || {{}}; const portStatus = DATA.port_status || {{}}; const ifaces = DATA.interfaces || {{}}; + const portCfg = DATA.port_config || {{}}; const pwr = DATA.power_supplies || {{}}; const temps = DATA.temperature_sensors || {{}}; - // Determine SFP slot states: need to map connector to interface link status - function sfpState(connIdx) {{ - const sfp = sfpInfo[connIdx]; - const present = sfp && sfp.present === '1'; - if (!present) return 'empty'; - // Check interface link — interfaces 1-4 map to connectors 1-4 roughly + // Connector type: "14" = SFP, "2" = RJ45/copper + function connType(connIdx) {{ + const c = connectors[connIdx]; + return (c && c.type === '14') ? 'sfp' : 'rj45'; + }} + + // Determine slot state based on connector type and interface/SFP status + function slotState(connIdx) {{ const iface = ifaces[connIdx]; - if (iface && iface.ifOperStatus === 'up') return 'present-link'; - return 'present-nolink'; + if (connType(connIdx) === 'sfp') {{ + const sfp = sfpInfo[connIdx]; + const present = sfp && sfp.present === '1'; + if (!present) return 'empty'; + if (iface && isUp(iface.ifOperStatus)) return 'present-link'; + return 'present-nolink'; + }} else {{ + // RJ45/copper — no SFP presence; use link status only + if (iface && isUp(iface.ifOperStatus)) return 'present-link'; + if (iface && isDown(iface.ifOperStatus)) return 'present-nolink'; + return 'empty'; + }} }} function sfpLabel(connIdx) {{ @@ -649,22 +1025,54 @@ function renderPanel() {{ return pn || sfp.vendor || ''; }} + // Get port label (ifName or port_config name) and alias + function portInfo(connIdx) {{ + const iface = ifaces[connIdx] || {{}}; + const cfg = portCfg[connIdx] || {{}}; + const label = iface.ifName || cfg.name || ''; + const alias = (cfg.alias && cfg.alias.trim()) || (iface.ifAlias && iface.ifAlias.trim()) || ''; + return {{ label, alias }}; + }} + + // Build port slots dynamically from connectors 1-4 let slots = ''; for (let i = 1; i <= 4; i++) {{ - const state = sfpState(String(i)); - const label = sfpLabel(String(i)); - const icon = state === 'empty' ? '' : - state === 'present-link' ? '' : - ''; - slots += `
- ${{icon}}SFP-${{i}} - ${{esc(label)}} + 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-${{i}}` : `RJ45-${{i}}`); + + let icon, detail; + if (isSfp) {{ + icon = state === 'empty' ? '' : + state === 'present-link' ? '' : + ''; + detail = sfpLabel(idx); + }} else {{ + icon = ''; + detail = state === 'present-link' ? 'UP' : state === 'present-nolink' ? 'DOWN' : ''; + }} + + const labelParts = []; + if (pi.label) labelParts.push(esc(pi.label)); + if (pi.alias) labelParts.push(`${{esc(pi.alias)}}`); + const belowLabel = labelParts.length + ? `
${{labelParts.join('
')}}
` + : ''; + slots += `
+
+ ${{icon}}${{esc(slotName)}} + ${{esc(detail)}} +
+ ${{belowLabel}}
`; }} // Management port const mgmtIf = ifaces['5']; - const mgmtUp = mgmtIf && mgmtIf.ifOperStatus === 'up'; + const mgmtUp = mgmtIf && isUp(mgmtIf.ifOperStatus); const mgmtSlot = `