From 0d9fe9015e324fb541cfb11b2a73b2795e350c08 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 27 Jan 2026 17:44:32 +0100 Subject: [PATCH] feat(netdiag): Add SecuBox Network Diagnostics dashboard New LuCI application for DSA switch port monitoring: - Real-time port status (link, speed, duplex) - Error counters (CRC, frame, FIFO, drops) - Alert thresholds (normal/warning/critical) - Interface detail modal with ethtool output - Kernel message logs (dmesg) - Auto-refresh polling (5s/10s/30s) - Export log functionality - SecuBox dark theme styling Co-Authored-By: Claude Opus 4.5 --- .../secubox/luci-app-secubox-netdiag/Makefile | 34 + .../luci-app-secubox-netdiag/README.md | 143 +++++ .../resources/secubox-netdiag/netdiag.css | 550 +++++++++++++++++ .../view/secubox-netdiag/overview.js | 581 ++++++++++++++++++ .../usr/libexec/rpcd/luci.secubox-netdiag | 512 +++++++++++++++ .../luci/menu.d/luci-app-secubox-netdiag.json | 14 + .../rpcd/acl.d/luci-app-secubox-netdiag.json | 22 + .../resources/view/secubox-portal/apps.js | 3 +- 8 files changed, 1858 insertions(+), 1 deletion(-) create mode 100644 package/secubox/luci-app-secubox-netdiag/Makefile create mode 100644 package/secubox/luci-app-secubox-netdiag/README.md create mode 100644 package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/secubox-netdiag/netdiag.css create mode 100644 package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/view/secubox-netdiag/overview.js create mode 100755 package/secubox/luci-app-secubox-netdiag/root/usr/libexec/rpcd/luci.secubox-netdiag create mode 100644 package/secubox/luci-app-secubox-netdiag/root/usr/share/luci/menu.d/luci-app-secubox-netdiag.json create mode 100644 package/secubox/luci-app-secubox-netdiag/root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json diff --git a/package/secubox/luci-app-secubox-netdiag/Makefile b/package/secubox/luci-app-secubox-netdiag/Makefile new file mode 100644 index 00000000..6245a189 --- /dev/null +++ b/package/secubox/luci-app-secubox-netdiag/Makefile @@ -0,0 +1,34 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-secubox-netdiag +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=SecuBox Team +PKG_LICENSE:=MIT + +LUCI_TITLE:=SecuBox Network Diagnostics Dashboard +LUCI_DESCRIPTION:=Real-time DSA switch port statistics, error monitoring, and network health diagnostics +LUCI_DEPENDS:=+luci-base +ethtool +LUCI_PKGARCH:=all + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/$(PKG_NAME)/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.secubox-netdiag $(1)/usr/libexec/rpcd/ + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-secubox-netdiag.json $(1)/usr/share/luci/menu.d/ + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json $(1)/usr/share/rpcd/acl.d/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/secubox-netdiag + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/secubox-netdiag/*.js $(1)/www/luci-static/resources/view/secubox-netdiag/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/secubox-netdiag + $(INSTALL_DATA) ./htdocs/luci-static/resources/secubox-netdiag/*.css $(1)/www/luci-static/resources/secubox-netdiag/ +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/luci-app-secubox-netdiag/README.md b/package/secubox/luci-app-secubox-netdiag/README.md new file mode 100644 index 00000000..06d39cf5 --- /dev/null +++ b/package/secubox/luci-app-secubox-netdiag/README.md @@ -0,0 +1,143 @@ +# SecuBox Network Diagnostics + +Real-time DSA switch port statistics and network error monitoring dashboard for OpenWrt. + +## Features + +- **Switch Port Status Panel**: Visual representation of DSA switch ports with link state, speed, and duplex indicators +- **Error Monitoring Widget**: Real-time error tracking with alert thresholds (normal/warning/critical) +- **Interface Details**: Full ethtool output, driver statistics, and kernel message logs +- **Auto-refresh**: Configurable polling interval (5s, 10s, 30s, or manual) +- **Responsive Design**: Mobile-friendly interface with SecuBox dark theme + +## Supported Hardware + +- MOCHAbin (Marvell Armada 8040) with mvpp2 driver +- Any OpenWrt device with DSA switch topology +- Standalone Ethernet interfaces (non-DSA) + +## Installation + +```bash +# Build with SDK +cd secubox-tools/sdk +make package/luci-app-secubox-netdiag/compile V=s + +# Install on device +opkg install luci-app-secubox-netdiag_*.ipk +``` + +## Dependencies + +- luci-base +- ethtool + +## Menu Location + +SecuBox > Network Diagnostics + +## Error Metrics Monitored + +| Metric | Description | +|--------|-------------| +| rx_crc_errors | CRC/FCS checksum errors | +| rx_frame_errors | Framing errors | +| rx_fifo_errors | FIFO overrun errors | +| rx_missed_errors | Missed packets (buffer full) | +| tx_aborted_errors | TX aborts | +| tx_carrier_errors | Carrier sense errors | +| collisions | Ethernet collisions | +| rx_dropped | Receive drops | +| tx_dropped | Transmit drops | + +## Alert Thresholds + +| Level | Condition | Indicator | +|-------|-----------|-----------| +| Normal | 0 errors/minute | Green | +| Warning | 1-10 errors/minute | Yellow | +| Critical | >10 errors/minute | Red (pulsing) | + +## RPCD API + +### Methods + +``` +luci.secubox-netdiag + get_switch_status - All interfaces with DSA topology + get_interface_details { interface: string } - Full ethtool/dmesg details + get_error_history { interface: string, minutes: int } - Error timeline + get_topology - DSA switch structure + clear_counters { interface: string } - Clear error history +``` + +### Example ubus call + +```bash +ubus call luci.secubox-netdiag get_switch_status +``` + +## Data Sources + +- `/sys/class/net/*/statistics/*` - Kernel statistics +- `/sys/class/net/*/carrier` - Link state +- `/sys/class/net/*/master` - DSA topology +- `ethtool ` - Link parameters +- `ethtool -S ` - Driver statistics +- `dmesg` - Kernel messages + +## UI Components + +### Port Card +``` ++----------+ +| eth0 | +| [*] Up | +| 1G FD | +| OK | ++----------+ +``` + +### Error Monitor +``` +eth2 - CRC Errors (last 5 min) +[sparkline graph] 123/min (CRITICAL) +``` + +## Files + +``` +luci-app-secubox-netdiag/ + Makefile + htdocs/luci-static/resources/ + view/secubox-netdiag/ + overview.js # Main LuCI view + secubox-netdiag/ + netdiag.css # SecuBox theme styles + root/usr/ + libexec/rpcd/ + luci.secubox-netdiag # RPCD backend script + share/ + luci/menu.d/ + luci-app-secubox-netdiag.json + rpcd/acl.d/ + luci-app-secubox-netdiag.json +``` + +## Screenshots + +### Main Dashboard +- DSA switch ports in grid layout +- Standalone interfaces below +- Error monitor at bottom + +### Port Detail Modal +- Link status (speed, duplex, autoneg) +- Traffic statistics (bytes, packets) +- Error counters with deltas +- Recent kernel messages +- Clear History / Export Log buttons + +## License + +MIT diff --git a/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/secubox-netdiag/netdiag.css b/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/secubox-netdiag/netdiag.css new file mode 100644 index 00000000..b3d116a7 --- /dev/null +++ b/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/secubox-netdiag/netdiag.css @@ -0,0 +1,550 @@ +/* SecuBox Network Diagnostics Dashboard Styles */ + +:root { + --sb-primary: #1a5f7a; + --sb-primary-light: #2980b9; + --sb-success: #57cc99; + --sb-warning: #ffca3a; + --sb-critical: #ff595e; + --sb-bg-dark: #0d1b2a; + --sb-bg-card: #1b263b; + --sb-bg-card-hover: #243b55; + --sb-text: #e0e1dd; + --sb-text-muted: #8d99ae; + --sb-border: #415a77; +} + +.netdiag-container { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--sb-bg-dark); + color: var(--sb-text); + min-height: 100vh; + padding: 20px; +} + +/* Header */ +.netdiag-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding: 16px 20px; + background: linear-gradient(135deg, var(--sb-primary) 0%, var(--sb-primary-light) 100%); + border-radius: 12px; + box-shadow: 0 4px 20px rgba(26, 95, 122, 0.3); +} + +.netdiag-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 1.5rem; + font-weight: 700; + margin: 0; +} + +.netdiag-title-icon { + font-size: 1.75rem; +} + +.netdiag-refresh-control { + display: flex; + align-items: center; + gap: 12px; +} + +.netdiag-refresh-btn { + padding: 8px 16px; + background: rgba(255,255,255,0.15); + border: 1px solid rgba(255,255,255,0.3); + border-radius: 6px; + color: white; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.netdiag-refresh-btn:hover { + background: rgba(255,255,255,0.25); +} + +.netdiag-refresh-select { + padding: 8px 12px; + background: rgba(255,255,255,0.15); + border: 1px solid rgba(255,255,255,0.3); + border-radius: 6px; + color: white; + font-size: 0.9rem; + cursor: pointer; +} + +.netdiag-refresh-select option { + background: var(--sb-bg-dark); + color: var(--sb-text); +} + +/* Section containers */ +.netdiag-section { + background: var(--sb-bg-card); + border: 1px solid var(--sb-border); + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; +} + +.netdiag-section-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--sb-border); +} + +.netdiag-section-icon { + font-size: 1.25rem; +} + +.netdiag-section-title { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + flex: 1; +} + +/* Port grid */ +.netdiag-ports-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 16px; +} + +/* Port card */ +.netdiag-port { + background: var(--sb-bg-dark); + border: 2px solid var(--sb-border); + border-radius: 10px; + padding: 16px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.netdiag-port:hover { + background: var(--sb-bg-card-hover); + border-color: var(--sb-primary); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} + +.netdiag-port.port-up { + border-color: var(--sb-success); +} + +.netdiag-port.port-down { + border-color: var(--sb-text-muted); + opacity: 0.7; +} + +.netdiag-port.port-warning { + border-color: var(--sb-warning); +} + +.netdiag-port.port-critical { + border-color: var(--sb-critical); + animation: pulse-error 1.5s infinite; +} + +@keyframes pulse-error { + 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 89, 94, 0.4); } + 50% { box-shadow: 0 0 0 8px rgba(255, 89, 94, 0); } +} + +.netdiag-port-name { + font-size: 1rem; + font-weight: 600; + margin-bottom: 8px; + font-family: 'JetBrains Mono', monospace; +} + +.netdiag-port-status { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 0.85rem; + margin-bottom: 6px; +} + +.netdiag-port-indicator { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.netdiag-port-indicator.up { background: var(--sb-success); } +.netdiag-port-indicator.down { background: var(--sb-text-muted); } +.netdiag-port-indicator.warning { background: var(--sb-warning); } +.netdiag-port-indicator.critical { background: var(--sb-critical); } + +.netdiag-port-speed { + font-size: 0.8rem; + color: var(--sb-text-muted); +} + +.netdiag-port-errors { + font-size: 0.75rem; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--sb-border); +} + +.netdiag-port-errors.ok { color: var(--sb-success); } +.netdiag-port-errors.warning { color: var(--sb-warning); } +.netdiag-port-errors.critical { color: var(--sb-critical); } + +/* Error monitor widget */ +.netdiag-error-monitor { + background: var(--sb-bg-dark); + border-radius: 8px; + padding: 16px; +} + +.netdiag-error-interface { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--sb-bg-card); + border-radius: 8px; + margin-bottom: 12px; + border-left: 4px solid var(--sb-border); +} + +.netdiag-error-interface.warning { + border-left-color: var(--sb-warning); +} + +.netdiag-error-interface.critical { + border-left-color: var(--sb-critical); +} + +.netdiag-error-info { + flex: 1; +} + +.netdiag-error-iface-name { + font-weight: 600; + margin-bottom: 4px; +} + +.netdiag-error-stats { + font-size: 0.85rem; + color: var(--sb-text-muted); +} + +.netdiag-error-rate { + font-size: 0.9rem; + font-weight: 600; +} + +.netdiag-error-rate.warning { color: var(--sb-warning); } +.netdiag-error-rate.critical { color: var(--sb-critical); } + +/* Sparkline */ +.netdiag-sparkline { + width: 120px; + height: 32px; + margin: 0 16px; +} + +.netdiag-sparkline svg { + width: 100%; + height: 100%; +} + +.netdiag-sparkline-path { + fill: none; + stroke: var(--sb-warning); + stroke-width: 2; +} + +.netdiag-sparkline-area { + fill: rgba(255, 202, 58, 0.2); +} + +/* Detail modal */ +.netdiag-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.netdiag-modal-content { + background: var(--sb-bg-card); + border: 1px solid var(--sb-border); + border-radius: 16px; + width: 100%; + max-width: 700px; + max-height: 90vh; + overflow-y: auto; +} + +.netdiag-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--sb-border); + background: var(--sb-primary); + border-radius: 16px 16px 0 0; +} + +.netdiag-modal-title { + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; +} + +.netdiag-modal-close { + background: rgba(255,255,255,0.2); + border: none; + color: white; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; +} + +.netdiag-modal-close:hover { + background: rgba(255,255,255,0.3); +} + +.netdiag-modal-body { + padding: 20px; +} + +/* Detail sections */ +.netdiag-detail-section { + margin-bottom: 20px; +} + +.netdiag-detail-title { + font-size: 0.9rem; + font-weight: 600; + color: var(--sb-primary-light); + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.netdiag-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} + +.netdiag-detail-item { + display: flex; + justify-content: space-between; + padding: 8px 12px; + background: var(--sb-bg-dark); + border-radius: 6px; +} + +.netdiag-detail-label { + color: var(--sb-text-muted); + font-size: 0.85rem; +} + +.netdiag-detail-value { + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + font-size: 0.85rem; +} + +/* Error table in detail */ +.netdiag-error-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.netdiag-error-table th, +.netdiag-error-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--sb-border); +} + +.netdiag-error-table th { + color: var(--sb-text-muted); + font-weight: 500; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.5px; +} + +.netdiag-error-table td.delta-up { + color: var(--sb-critical); +} + +.netdiag-error-table td.delta-up::after { + content: ' \25B2'; +} + +/* Kernel log */ +.netdiag-dmesg { + background: #000; + border-radius: 6px; + padding: 12px; + max-height: 200px; + overflow-y: auto; + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + line-height: 1.6; +} + +.netdiag-dmesg-line { + color: #0f0; + white-space: pre-wrap; + word-break: break-all; +} + +.netdiag-dmesg-line.error { + color: var(--sb-critical); +} + +/* Action buttons */ +.netdiag-actions { + display: flex; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--sb-border); +} + +.netdiag-btn { + padding: 10px 20px; + border-radius: 6px; + border: none; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s; +} + +.netdiag-btn-primary { + background: var(--sb-primary); + color: white; +} + +.netdiag-btn-primary:hover { + background: var(--sb-primary-light); +} + +.netdiag-btn-secondary { + background: var(--sb-bg-dark); + color: var(--sb-text); + border: 1px solid var(--sb-border); +} + +.netdiag-btn-secondary:hover { + background: var(--sb-bg-card-hover); +} + +.netdiag-btn-danger { + background: var(--sb-critical); + color: white; +} + +.netdiag-btn-danger:hover { + background: #e04347; +} + +/* Loading state */ +.netdiag-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--sb-text-muted); +} + +.netdiag-spinner { + width: 24px; + height: 24px; + border: 3px solid var(--sb-border); + border-top-color: var(--sb-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 12px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Empty state */ +.netdiag-empty { + text-align: center; + padding: 40px; + color: var(--sb-text-muted); +} + +/* Responsive */ +@media (max-width: 768px) { + .netdiag-container { + padding: 12px; + } + + .netdiag-header { + flex-direction: column; + gap: 12px; + text-align: center; + } + + .netdiag-ports-grid { + grid-template-columns: repeat(2, 1fr); + } + + .netdiag-modal-content { + max-width: 100%; + max-height: 100%; + border-radius: 0; + } + + .netdiag-modal-header { + border-radius: 0; + } + + .netdiag-detail-grid { + grid-template-columns: 1fr; + } + + .netdiag-actions { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .netdiag-ports-grid { + grid-template-columns: 1fr; + } +} diff --git a/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/view/secubox-netdiag/overview.js b/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/view/secubox-netdiag/overview.js new file mode 100644 index 00000000..2aa9927f --- /dev/null +++ b/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/view/secubox-netdiag/overview.js @@ -0,0 +1,581 @@ +'use strict'; +'require view'; +'require dom'; +'require ui'; +'require rpc'; +'require poll'; + +var callNetdiagStatus = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'get_switch_status', + expect: { ports: [] } +}); + +var callNetdiagDetails = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'get_interface_details', + params: ['interface'], + expect: {} +}); + +var callNetdiagHistory = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'get_error_history', + params: ['interface', 'minutes'], + expect: { timeline: [] } +}); + +var callNetdiagTopology = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'get_topology', + expect: {} +}); + +var callClearCounters = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'clear_counters', + params: ['interface'], + expect: {} +}); + +return view.extend({ + refreshInterval: 5000, + pollHandle: null, + + load: function() { + return Promise.all([ + callNetdiagStatus(), + callNetdiagTopology() + ]); + }, + + render: function(data) { + var ports = data[0] || []; + var topoData = data[1] || {}; + var topology = topoData.topology || {}; + var self = this; + + var container = E('div', { 'class': 'netdiag-container' }, [ + E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-netdiag/netdiag.css') }), + this.renderHeader(), + E('div', { 'id': 'netdiag-content' }, [ + this.renderSwitchSection(ports, topology), + this.renderStandaloneSection(ports, topology), + this.renderErrorMonitor(ports) + ]) + ]); + + // Start polling + this.startPolling(); + + return container; + }, + + renderHeader: function() { + var self = this; + + return E('div', { 'class': 'netdiag-header' }, [ + E('h1', { 'class': 'netdiag-title' }, [ + E('span', { 'class': 'netdiag-title-icon' }, '\uD83D\uDD0C'), + _('Network Diagnostics') + ]), + E('div', { 'class': 'netdiag-refresh-control' }, [ + E('button', { + 'class': 'netdiag-refresh-btn', + 'click': function() { self.refreshData(); } + }, '\u21BB ' + _('Refresh')), + E('select', { + 'class': 'netdiag-refresh-select', + 'change': function(ev) { + self.refreshInterval = parseInt(ev.target.value, 10); + self.restartPolling(); + } + }, [ + E('option', { 'value': '5000', 'selected': true }, _('5 seconds')), + E('option', { 'value': '10000' }, _('10 seconds')), + E('option', { 'value': '30000' }, _('30 seconds')), + E('option', { 'value': '0' }, _('Manual')) + ]) + ]) + ]); + }, + + renderSwitchSection: function(ports, topology) { + var self = this; + var switches = topology.switches || []; + + // If no DSA topology detected, return empty + if (switches.length === 0) { + // Check if any ports have a master + var dsaPorts = ports.filter(function(p) { return p.is_dsa_port; }); + if (dsaPorts.length === 0) { + return E('div'); + } + } + + // Group ports by master + var portsByMaster = {}; + ports.forEach(function(port) { + if (port.master) { + if (!portsByMaster[port.master]) { + portsByMaster[port.master] = []; + } + portsByMaster[port.master].push(port); + } + }); + + var sections = []; + Object.keys(portsByMaster).forEach(function(master) { + var switchPorts = portsByMaster[master]; + + sections.push(E('div', { 'class': 'netdiag-section' }, [ + E('div', { 'class': 'netdiag-section-header' }, [ + E('span', { 'class': 'netdiag-section-icon' }, '\uD83D\uDD00'), + E('h2', { 'class': 'netdiag-section-title' }, + _('DSA Switch') + ' (' + master + ')') + ]), + E('div', { 'class': 'netdiag-ports-grid' }, + switchPorts.map(function(port) { + return self.renderPortCard(port); + }) + ) + ])); + }); + + return E('div', { 'id': 'netdiag-switches' }, sections); + }, + + renderStandaloneSection: function(ports, topology) { + var self = this; + + // Get standalone interfaces (no DSA master) + var standalone = ports.filter(function(p) { + return !p.is_dsa_port && !p.name.match(/^(br-|lo|docker|veth|tun|tap)/); + }); + + if (standalone.length === 0) { + return E('div'); + } + + return E('div', { 'class': 'netdiag-section', 'id': 'netdiag-standalone' }, [ + E('div', { 'class': 'netdiag-section-header' }, [ + E('span', { 'class': 'netdiag-section-icon' }, '\uD83C\uDF10'), + E('h2', { 'class': 'netdiag-section-title' }, _('Standalone Interfaces')) + ]), + E('div', { 'class': 'netdiag-ports-grid' }, + standalone.map(function(port) { + return self.renderPortCard(port); + }) + ) + ]); + }, + + renderPortCard: function(port) { + var self = this; + var link = port.link; + var speed = port.speed || 0; + var duplex = (port.duplex || '').toLowerCase(); + var alertLevel = port.alert_level || 'normal'; + + var portClass = 'netdiag-port'; + if (!link) { + portClass += ' port-down'; + } else if (alertLevel === 'critical') { + portClass += ' port-critical'; + } else if (alertLevel === 'warning') { + portClass += ' port-warning'; + } else { + portClass += ' port-up'; + } + + var indicatorClass = 'netdiag-port-indicator'; + if (!link) { + indicatorClass += ' down'; + } else if (alertLevel === 'critical') { + indicatorClass += ' critical'; + } else if (alertLevel === 'warning') { + indicatorClass += ' warning'; + } else { + indicatorClass += ' up'; + } + + var speedText = '-'; + if (link && speed > 0) { + speedText = (speed >= 1000 ? (speed / 1000) + 'G' : speed + 'M'); + speedText += ' ' + (duplex === 'full' ? 'FD' : 'HD'); + } + + var errorText = this.getErrorSummary(port); + var errorClass = 'netdiag-port-errors'; + if (alertLevel === 'critical') { + errorClass += ' critical'; + } else if (alertLevel === 'warning') { + errorClass += ' warning'; + } else { + errorClass += ' ok'; + } + + return E('div', { + 'class': portClass, + 'data-interface': port.name, + 'click': function() { self.showPortDetails(port.name); } + }, [ + E('div', { 'class': 'netdiag-port-name' }, port.name), + E('div', { 'class': 'netdiag-port-status' }, [ + E('span', { 'class': indicatorClass }), + link ? _('Up') : _('Down') + ]), + E('div', { 'class': 'netdiag-port-speed' }, speedText), + E('div', { 'class': errorClass }, errorText) + ]); + }, + + getErrorSummary: function(port) { + var errors = port.errors || {}; + var total = 0; + + ['rx_crc_errors', 'rx_frame_errors', 'rx_fifo_errors', 'rx_dropped', + 'tx_dropped', 'collisions'].forEach(function(key) { + total += parseInt(errors[key] || 0, 10); + }); + + if (total === 0) { + return '\u2713 OK'; + } + + var rate = port.error_rate || 0; + if (rate > 0) { + return '\u26A0 ' + total + ' err (' + rate + '/min)'; + } + + return '\u26A0 ' + total + ' err'; + }, + + renderErrorMonitor: function(ports) { + var self = this; + + // Filter ports with errors + var errorPorts = ports.filter(function(p) { + return (p.alert_level === 'warning' || p.alert_level === 'critical') || + (p.error_rate && p.error_rate > 0); + }); + + // Sort by error rate descending + errorPorts.sort(function(a, b) { + return (b.error_rate || 0) - (a.error_rate || 0); + }); + + if (errorPorts.length === 0) { + return E('div', { 'class': 'netdiag-section', 'id': 'netdiag-errors' }, [ + E('div', { 'class': 'netdiag-section-header' }, [ + E('span', { 'class': 'netdiag-section-icon' }, '\u26A0\uFE0F'), + E('h2', { 'class': 'netdiag-section-title' }, _('Error Monitor')) + ]), + E('div', { 'class': 'netdiag-empty' }, [ + E('span', {}, '\u2705 '), + _('No errors detected on any interface') + ]) + ]); + } + + return E('div', { 'class': 'netdiag-section', 'id': 'netdiag-errors' }, [ + E('div', { 'class': 'netdiag-section-header' }, [ + E('span', { 'class': 'netdiag-section-icon' }, '\u26A0\uFE0F'), + E('h2', { 'class': 'netdiag-section-title' }, _('Error Monitor')) + ]), + E('div', { 'class': 'netdiag-error-monitor' }, + errorPorts.slice(0, 5).map(function(port) { + return self.renderErrorRow(port); + }) + ) + ]); + }, + + renderErrorRow: function(port) { + var self = this; + var errors = port.errors || {}; + var crcErrors = parseInt(errors.rx_crc_errors || 0, 10); + var rate = port.error_rate || 0; + var alertLevel = port.alert_level || 'normal'; + + var rowClass = 'netdiag-error-interface'; + if (alertLevel === 'critical') rowClass += ' critical'; + else if (alertLevel === 'warning') rowClass += ' warning'; + + var rateClass = 'netdiag-error-rate'; + if (alertLevel === 'critical') rateClass += ' critical'; + else if (alertLevel === 'warning') rateClass += ' warning'; + + return E('div', { 'class': rowClass }, [ + E('div', { 'class': 'netdiag-error-info' }, [ + E('div', { 'class': 'netdiag-error-iface-name' }, port.name), + E('div', { 'class': 'netdiag-error-stats' }, + _('CRC: %d, Frame: %d, FIFO: %d').format( + crcErrors, + parseInt(errors.rx_frame_errors || 0, 10), + parseInt(errors.rx_fifo_errors || 0, 10) + ) + ) + ]), + E('div', { 'class': 'netdiag-sparkline', 'data-interface': port.name }), + E('div', { 'class': rateClass }, rate + '/min'), + E('button', { + 'class': 'netdiag-btn netdiag-btn-secondary', + 'style': 'padding: 6px 12px; font-size: 0.8rem;', + 'click': function(ev) { + ev.stopPropagation(); + self.showPortDetails(port.name); + } + }, _('Details')) + ]); + }, + + showPortDetails: function(iface) { + var self = this; + + // Show loading modal + var modal = E('div', { 'class': 'netdiag-modal' }, [ + E('div', { 'class': 'netdiag-modal-content' }, [ + E('div', { 'class': 'netdiag-modal-header' }, [ + E('span', { 'class': 'netdiag-modal-title' }, [ + '\uD83D\uDD0C ', + iface + ' ' + _('Details') + ]), + E('button', { + 'class': 'netdiag-modal-close', + 'click': function() { modal.remove(); } + }, '\u2715') + ]), + E('div', { 'class': 'netdiag-modal-body' }, [ + E('div', { 'class': 'netdiag-loading' }, [ + E('div', { 'class': 'netdiag-spinner' }), + _('Loading interface details...') + ]) + ]) + ]) + ]); + + document.body.appendChild(modal); + + // Fetch details + callNetdiagDetails(iface).then(function(details) { + var body = modal.querySelector('.netdiag-modal-body'); + body.innerHTML = ''; + + if (details.error) { + body.appendChild(E('div', { 'class': 'netdiag-empty' }, details.message)); + return; + } + + body.appendChild(self.renderDetailContent(details, iface)); + }).catch(function(err) { + var body = modal.querySelector('.netdiag-modal-body'); + body.innerHTML = ''; + body.appendChild(E('div', { 'class': 'netdiag-empty' }, _('Error loading details: ') + err)); + }); + }, + + renderDetailContent: function(details, iface) { + var self = this; + var ethtool = details.ethtool || {}; + var stats = details.stats || {}; + var errors = details.errors || {}; + var dmesg = details.dmesg || []; + var driverInfo = details.driver_info || {}; + + return E('div', {}, [ + // Link Status + E('div', { 'class': 'netdiag-detail-section' }, [ + E('div', { 'class': 'netdiag-detail-title' }, _('Link Status')), + E('div', { 'class': 'netdiag-detail-grid' }, [ + this.renderDetailItem(_('Speed'), ethtool.speed || '-'), + this.renderDetailItem(_('Duplex'), ethtool.duplex || '-'), + this.renderDetailItem(_('Auto-negotiation'), ethtool.auto_negotiation || '-'), + this.renderDetailItem(_('Link Detected'), ethtool.link_detected || '-'), + this.renderDetailItem(_('Port'), ethtool.port || '-'), + this.renderDetailItem(_('Driver'), driverInfo.driver || '-') + ]) + ]), + + // Traffic Statistics + E('div', { 'class': 'netdiag-detail-section' }, [ + E('div', { 'class': 'netdiag-detail-title' }, _('Traffic Statistics')), + E('div', { 'class': 'netdiag-detail-grid' }, [ + this.renderDetailItem(_('RX Bytes'), this.formatBytes(stats.rx_bytes)), + this.renderDetailItem(_('TX Bytes'), this.formatBytes(stats.tx_bytes)), + this.renderDetailItem(_('RX Packets'), this.formatNumber(stats.rx_packets)), + this.renderDetailItem(_('TX Packets'), this.formatNumber(stats.tx_packets)), + this.renderDetailItem(_('RX Dropped'), stats.rx_dropped || '0'), + this.renderDetailItem(_('TX Dropped'), stats.tx_dropped || '0') + ]) + ]), + + // Error Counters + E('div', { 'class': 'netdiag-detail-section' }, [ + E('div', { 'class': 'netdiag-detail-title' }, _('Error Counters')), + E('table', { 'class': 'netdiag-error-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, _('Counter')), + E('th', {}, _('Value')), + E('th', {}, _('Status')) + ]) + ]), + E('tbody', {}, [ + this.renderErrorRow2('rx_crc_errors', errors.rx_crc_errors), + this.renderErrorRow2('rx_frame_errors', errors.rx_frame_errors), + this.renderErrorRow2('rx_fifo_errors', errors.rx_fifo_errors), + this.renderErrorRow2('rx_missed_errors', errors.rx_missed_errors), + this.renderErrorRow2('tx_aborted_errors', errors.tx_aborted_errors), + this.renderErrorRow2('tx_carrier_errors', errors.tx_carrier_errors), + this.renderErrorRow2('collisions', errors.collisions) + ]) + ]) + ]), + + // Kernel Messages + dmesg.length > 0 ? E('div', { 'class': 'netdiag-detail-section' }, [ + E('div', { 'class': 'netdiag-detail-title' }, _('Recent Kernel Messages')), + E('div', { 'class': 'netdiag-dmesg' }, + dmesg.slice(-10).map(function(line) { + var lineClass = 'netdiag-dmesg-line'; + if (line.match(/error|fail|bad/i)) { + lineClass += ' error'; + } + return E('div', { 'class': lineClass }, line); + }) + ) + ]) : E('div'), + + // Actions + E('div', { 'class': 'netdiag-actions' }, [ + E('button', { + 'class': 'netdiag-btn netdiag-btn-secondary', + 'click': function() { + self.clearCounters(iface); + } + }, _('Clear History')), + E('button', { + 'class': 'netdiag-btn netdiag-btn-secondary', + 'click': function() { + self.exportLog(iface, details); + } + }, _('Export Log')) + ]) + ]); + }, + + renderDetailItem: function(label, value) { + return E('div', { 'class': 'netdiag-detail-item' }, [ + E('span', { 'class': 'netdiag-detail-label' }, label), + E('span', { 'class': 'netdiag-detail-value' }, value) + ]); + }, + + renderErrorRow2: function(name, value) { + var val = parseInt(value || 0, 10); + var status = val > 0 ? '\u26A0' : '\u2713'; + var tdClass = val > 0 ? 'delta-up' : ''; + + return E('tr', {}, [ + E('td', {}, name), + E('td', { 'class': tdClass }, String(val)), + E('td', {}, status) + ]); + }, + + formatBytes: function(bytes) { + bytes = parseInt(bytes || 0, 10); + if (bytes === 0) return '0 B'; + var k = 1024; + var sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + formatNumber: function(num) { + num = parseInt(num || 0, 10); + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; + if (num >= 1000) return (num / 1000).toFixed(1) + 'K'; + return String(num); + }, + + clearCounters: function(iface) { + var self = this; + callClearCounters(iface).then(function(result) { + if (result.success) { + ui.addNotification(null, E('p', _('History cleared for %s').format(iface)), 'info'); + self.refreshData(); + } else { + ui.addNotification(null, E('p', result.message), 'warning'); + } + }); + }, + + exportLog: function(iface, details) { + var content = 'SecuBox Network Diagnostics Export\n'; + content += 'Interface: ' + iface + '\n'; + content += 'Timestamp: ' + new Date().toISOString() + '\n'; + content += '\n--- Ethtool ---\n'; + content += JSON.stringify(details.ethtool, null, 2) + '\n'; + content += '\n--- Statistics ---\n'; + content += JSON.stringify(details.stats, null, 2) + '\n'; + content += '\n--- Errors ---\n'; + content += JSON.stringify(details.errors, null, 2) + '\n'; + content += '\n--- Kernel Messages ---\n'; + (details.dmesg || []).forEach(function(line) { + content += line + '\n'; + }); + + var blob = new Blob([content], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'netdiag-' + iface + '-' + Date.now() + '.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + + refreshData: function() { + var self = this; + var content = document.getElementById('netdiag-content'); + + if (!content) return; + + callNetdiagStatus().then(function(ports) { + callNetdiagTopology().then(function(topoData) { + var topology = (topoData && topoData.topology) ? topoData.topology : {}; + + // Update content + content.innerHTML = ''; + content.appendChild(self.renderSwitchSection(ports || [], topology)); + content.appendChild(self.renderStandaloneSection(ports || [], topology)); + content.appendChild(self.renderErrorMonitor(ports || [])); + }); + }); + }, + + startPolling: function() { + var self = this; + + if (this.refreshInterval > 0) { + this.pollHandle = poll.add(function() { + self.refreshData(); + }, this.refreshInterval / 1000); + } + }, + + restartPolling: function() { + if (this.pollHandle) { + poll.remove(this.pollHandle); + this.pollHandle = null; + } + this.startPolling(); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-secubox-netdiag/root/usr/libexec/rpcd/luci.secubox-netdiag b/package/secubox/luci-app-secubox-netdiag/root/usr/libexec/rpcd/luci.secubox-netdiag new file mode 100755 index 00000000..ec622ab9 --- /dev/null +++ b/package/secubox/luci-app-secubox-netdiag/root/usr/libexec/rpcd/luci.secubox-netdiag @@ -0,0 +1,512 @@ +#!/bin/sh +# SecuBox Network Diagnostics RPCD Backend +# Provides DSA switch port statistics and error monitoring + +. /usr/share/libubox/jshn.sh + +# Error history storage (in-memory via temp files) +HISTORY_DIR="/tmp/secubox-netdiag" +HISTORY_INTERVAL=5 +HISTORY_SAMPLES=60 # 5 minutes at 5-second intervals + +# Ensure history directory exists +mkdir -p "$HISTORY_DIR" 2>/dev/null + +# Helper: Read a sysfs statistic file +read_stat() { + local iface="$1" + local stat="$2" + local path="/sys/class/net/${iface}/statistics/${stat}" + if [ -f "$path" ]; then + cat "$path" 2>/dev/null || echo "0" + else + echo "0" + fi +} + +# Helper: Get interface link state +get_link_state() { + local iface="$1" + local carrier="/sys/class/net/${iface}/carrier" + local operstate="/sys/class/net/${iface}/operstate" + + if [ -f "$carrier" ] && [ "$(cat "$carrier" 2>/dev/null)" = "1" ]; then + echo "up" + elif [ -f "$operstate" ]; then + cat "$operstate" 2>/dev/null + else + echo "unknown" + fi +} + +# Helper: Get DSA master interface +get_dsa_master() { + local iface="$1" + local master="/sys/class/net/${iface}/master" + if [ -L "$master" ]; then + basename "$(readlink "$master")" 2>/dev/null + else + echo "" + fi +} + +# Helper: Get speed and duplex via ethtool +get_ethtool_info() { + local iface="$1" + local result + + result=$(ethtool "$iface" 2>/dev/null) + if [ -n "$result" ]; then + local speed duplex autoneg link + speed=$(echo "$result" | grep -i "Speed:" | awk '{print $2}' | sed 's/Mb\/s//') + duplex=$(echo "$result" | grep -i "Duplex:" | awk '{print $2}') + autoneg=$(echo "$result" | grep -i "Auto-negotiation:" | awk '{print $2}') + link=$(echo "$result" | grep -i "Link detected:" | awk '{print $3}') + + json_add_int "speed" "${speed:-0}" + json_add_string "duplex" "${duplex:-unknown}" + json_add_string "autoneg" "${autoneg:-unknown}" + json_add_string "link_detected" "${link:-unknown}" + else + json_add_int "speed" 0 + json_add_string "duplex" "unknown" + json_add_string "autoneg" "unknown" + json_add_string "link_detected" "unknown" + fi +} + +# Helper: Get all interface statistics +get_interface_stats() { + local iface="$1" + + json_add_object "stats" + json_add_string "rx_bytes" "$(read_stat "$iface" rx_bytes)" + json_add_string "tx_bytes" "$(read_stat "$iface" tx_bytes)" + json_add_string "rx_packets" "$(read_stat "$iface" rx_packets)" + json_add_string "tx_packets" "$(read_stat "$iface" tx_packets)" + json_add_string "rx_dropped" "$(read_stat "$iface" rx_dropped)" + json_add_string "tx_dropped" "$(read_stat "$iface" tx_dropped)" + json_close_object +} + +# Helper: Get error counters +get_error_stats() { + local iface="$1" + + json_add_object "errors" + json_add_string "rx_crc_errors" "$(read_stat "$iface" rx_crc_errors)" + json_add_string "rx_frame_errors" "$(read_stat "$iface" rx_frame_errors)" + json_add_string "rx_fifo_errors" "$(read_stat "$iface" rx_fifo_errors)" + json_add_string "rx_missed_errors" "$(read_stat "$iface" rx_missed_errors)" + json_add_string "rx_length_errors" "$(read_stat "$iface" rx_length_errors)" + json_add_string "rx_over_errors" "$(read_stat "$iface" rx_over_errors)" + json_add_string "tx_aborted_errors" "$(read_stat "$iface" tx_aborted_errors)" + json_add_string "tx_carrier_errors" "$(read_stat "$iface" tx_carrier_errors)" + json_add_string "tx_fifo_errors" "$(read_stat "$iface" tx_fifo_errors)" + json_add_string "tx_heartbeat_errors" "$(read_stat "$iface" tx_heartbeat_errors)" + json_add_string "tx_window_errors" "$(read_stat "$iface" tx_window_errors)" + json_add_string "collisions" "$(read_stat "$iface" collisions)" + json_close_object +} + +# Helper: Get ARP/neighbor info for connected devices +get_connected_device() { + local iface="$1" + local neighbor + + # Check ARP table for devices on this interface + neighbor=$(ip neigh show dev "$iface" 2>/dev/null | grep -v "FAILED" | head -1) + if [ -n "$neighbor" ]; then + local ip mac + ip=$(echo "$neighbor" | awk '{print $1}') + mac=$(echo "$neighbor" | grep -oE '([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}' | head -1) + json_add_object "neighbor" + json_add_string "ip" "${ip:-}" + json_add_string "mac" "${mac:-}" + json_close_object + fi +} + +# Store error history sample +store_error_sample() { + local iface="$1" + local history_file="$HISTORY_DIR/${iface}.history" + local timestamp=$(date +%s) + + # Collect current error values + local rx_crc=$(read_stat "$iface" rx_crc_errors) + local rx_frame=$(read_stat "$iface" rx_frame_errors) + local rx_fifo=$(read_stat "$iface" rx_fifo_errors) + local rx_dropped=$(read_stat "$iface" rx_dropped) + local tx_dropped=$(read_stat "$iface" tx_dropped) + local collisions=$(read_stat "$iface" collisions) + + # Append to history file + echo "$timestamp $rx_crc $rx_frame $rx_fifo $rx_dropped $tx_dropped $collisions" >> "$history_file" + + # Keep only last HISTORY_SAMPLES entries + if [ -f "$history_file" ]; then + tail -n "$HISTORY_SAMPLES" "$history_file" > "${history_file}.tmp" + mv "${history_file}.tmp" "$history_file" + fi +} + +# Calculate error rate (errors per minute) +calc_error_rate() { + local iface="$1" + local history_file="$HISTORY_DIR/${iface}.history" + local now=$(date +%s) + local one_minute_ago=$((now - 60)) + + if [ ! -f "$history_file" ]; then + echo "0" + return + fi + + # Get oldest and newest samples within last minute + local first_sample last_sample + first_sample=$(awk -v t="$one_minute_ago" '$1 >= t {print; exit}' "$history_file") + last_sample=$(tail -1 "$history_file") + + if [ -z "$first_sample" ] || [ -z "$last_sample" ]; then + echo "0" + return + fi + + # Calculate delta for rx_crc_errors (column 2) + local first_crc=$(echo "$first_sample" | awk '{print $2}') + local last_crc=$(echo "$last_sample" | awk '{print $2}') + local delta=$((last_crc - first_crc)) + + [ "$delta" -lt 0 ] && delta=0 + echo "$delta" +} + +# Get error history for sparkline +get_error_history() { + local iface="$1" + local minutes="${2:-5}" + local history_file="$HISTORY_DIR/${iface}.history" + local now=$(date +%s) + local start_time=$((now - minutes * 60)) + + json_add_array "timeline" + + if [ -f "$history_file" ]; then + local prev_crc=0 + local first=1 + + while read -r line; do + local ts=$(echo "$line" | awk '{print $1}') + [ "$ts" -lt "$start_time" ] && continue + + local rx_crc=$(echo "$line" | awk '{print $2}') + local rx_frame=$(echo "$line" | awk '{print $3}') + local rx_fifo=$(echo "$line" | awk '{print $4}') + + # Calculate delta from previous sample + local delta_crc=0 + if [ "$first" = "0" ]; then + delta_crc=$((rx_crc - prev_crc)) + [ "$delta_crc" -lt 0 ] && delta_crc=0 + fi + first=0 + prev_crc="$rx_crc" + + json_add_object "" + json_add_int "timestamp" "$ts" + json_add_int "rx_crc_errors" "$delta_crc" + json_add_int "rx_crc_total" "$rx_crc" + json_add_int "rx_frame_errors" "$rx_frame" + json_add_int "rx_fifo_errors" "$rx_fifo" + json_close_object + done < "$history_file" + fi + + json_close_array +} + +# Method: get_switch_status +# Returns status of all network interfaces with DSA topology +get_switch_status() { + json_init + json_add_array "ports" + + # Iterate all network interfaces + for iface_path in /sys/class/net/*; do + [ ! -d "$iface_path" ] && continue + + local iface=$(basename "$iface_path") + + # Skip virtual/loopback interfaces + case "$iface" in + lo|br-*|docker*|veth*|tun*|tap*) continue ;; + esac + + # Store error sample for history + store_error_sample "$iface" + + json_add_object "" + json_add_string "name" "$iface" + + # Check if this is a DSA port + local master=$(get_dsa_master "$iface") + json_add_string "master" "$master" + json_add_boolean "is_dsa_port" "$([ -n "$master" ] && echo 1 || echo 0)" + + # Link state + local link_state=$(get_link_state "$iface") + json_add_string "operstate" "$link_state" + json_add_boolean "link" "$([ "$link_state" = "up" ] && echo 1 || echo 0)" + + # Speed/duplex from ethtool + get_ethtool_info "$iface" + + # Traffic statistics + get_interface_stats "$iface" + + # Error counters + get_error_stats "$iface" + + # Error rate (errors/minute) + local error_rate=$(calc_error_rate "$iface") + json_add_int "error_rate" "$error_rate" + + # Alert level based on error rate + local alert_level="normal" + [ "$error_rate" -gt 0 ] && [ "$error_rate" -le 10 ] && alert_level="warning" + [ "$error_rate" -gt 10 ] && alert_level="critical" + json_add_string "alert_level" "$alert_level" + + # Connected device info + get_connected_device "$iface" + + # MAC address + local mac="" + [ -f "/sys/class/net/${iface}/address" ] && mac=$(cat "/sys/class/net/${iface}/address" 2>/dev/null) + json_add_string "mac" "${mac:-}" + + # MTU + local mtu="" + [ -f "/sys/class/net/${iface}/mtu" ] && mtu=$(cat "/sys/class/net/${iface}/mtu" 2>/dev/null) + json_add_int "mtu" "${mtu:-1500}" + + json_close_object + done + + json_close_array + json_dump +} + +# Method: get_interface_details +# Returns detailed information for a specific interface +get_interface_details() { + local iface="$1" + + # Validate interface exists + if [ ! -d "/sys/class/net/${iface}" ]; then + json_init + json_add_boolean "error" 1 + json_add_string "message" "Interface not found: $iface" + json_dump + return + fi + + json_init + json_add_string "interface" "$iface" + + # Full ethtool output + json_add_object "ethtool" + local ethtool_out=$(ethtool "$iface" 2>/dev/null) + if [ -n "$ethtool_out" ]; then + # Parse key fields + json_add_string "speed" "$(echo "$ethtool_out" | grep -i 'Speed:' | awk '{print $2}')" + json_add_string "duplex" "$(echo "$ethtool_out" | grep -i 'Duplex:' | awk '{print $2}')" + json_add_string "auto_negotiation" "$(echo "$ethtool_out" | grep -i 'Auto-negotiation:' | awk '{print $2}')" + json_add_string "link_detected" "$(echo "$ethtool_out" | grep -i 'Link detected:' | awk '{print $3}')" + json_add_string "port" "$(echo "$ethtool_out" | grep -i 'Port:' | cut -d: -f2 | xargs)" + json_add_string "transceiver" "$(echo "$ethtool_out" | grep -i 'Transceiver:' | awk '{print $2}')" + + # Supported modes + local modes=$(echo "$ethtool_out" | grep -A20 'Supported link modes:' | grep -E '^\s+[0-9]+' | tr '\n' ' ') + json_add_string "supported_modes" "$modes" + + # Link partner + local partner=$(echo "$ethtool_out" | grep -A5 'Link partner' | grep -E '^\s+[0-9]+' | tr '\n' ' ') + json_add_string "link_partner" "$partner" + fi + json_close_object + + # Extended statistics (ethtool -S) + json_add_object "driver_stats" + local ethtool_s=$(ethtool -S "$iface" 2>/dev/null) + if [ -n "$ethtool_s" ]; then + echo "$ethtool_s" | grep -E '^\s+[a-z_]+:' | while read -r line; do + local key=$(echo "$line" | cut -d: -f1 | xargs) + local val=$(echo "$line" | cut -d: -f2 | xargs) + # Only include first 50 stats to avoid huge output + [ -n "$key" ] && [ -n "$val" ] && json_add_string "$key" "$val" + done + fi + json_close_object + + # Driver info (ethtool -i) + json_add_object "driver_info" + local ethtool_i=$(ethtool -i "$iface" 2>/dev/null) + if [ -n "$ethtool_i" ]; then + json_add_string "driver" "$(echo "$ethtool_i" | grep 'driver:' | cut -d: -f2 | xargs)" + json_add_string "version" "$(echo "$ethtool_i" | grep 'version:' | cut -d: -f2 | xargs)" + json_add_string "firmware" "$(echo "$ethtool_i" | grep 'firmware-version:' | cut -d: -f2 | xargs)" + json_add_string "bus_info" "$(echo "$ethtool_i" | grep 'bus-info:' | cut -d: -f2 | xargs)" + fi + json_close_object + + # Recent kernel messages + json_add_array "dmesg" + dmesg 2>/dev/null | grep -i "$iface" | tail -20 | while read -r line; do + json_add_string "" "$line" + done + json_close_array + + # Current stats + get_interface_stats "$iface" + get_error_stats "$iface" + + # Error history + get_error_history "$iface" 5 + + json_dump +} + +# Method: get_error_history (standalone) +get_error_history_method() { + local iface="$1" + local minutes="${2:-5}" + + if [ ! -d "/sys/class/net/${iface}" ]; then + json_init + json_add_boolean "error" 1 + json_add_string "message" "Interface not found: $iface" + json_dump + return + fi + + json_init + json_add_string "interface" "$iface" + get_error_history "$iface" "$minutes" + json_dump +} + +# Method: clear_counters +# Clear error history (counters are read-only in sysfs) +clear_counters() { + local iface="$1" + local history_file="$HISTORY_DIR/${iface}.history" + + json_init + if [ -n "$iface" ] && [ -f "$history_file" ]; then + rm -f "$history_file" + json_add_boolean "success" 1 + json_add_string "message" "Cleared history for $iface" + elif [ -z "$iface" ]; then + rm -f "$HISTORY_DIR"/*.history + json_add_boolean "success" 1 + json_add_string "message" "Cleared all history" + else + json_add_boolean "success" 0 + json_add_string "message" "No history found for $iface" + fi + json_dump +} + +# Method: get_topology +# Returns DSA switch topology +get_topology() { + json_init + json_add_object "topology" + + # Find DSA master interfaces + json_add_array "switches" + + for master_path in /sys/class/net/*/dsa; do + [ ! -d "$master_path" ] && continue + local master=$(dirname "$master_path" | xargs basename) + + json_add_object "" + json_add_string "master" "$master" + json_add_string "driver" "$(cat /sys/class/net/${master}/device/driver/module/name 2>/dev/null || echo 'unknown')" + + # Find ports belonging to this master + json_add_array "ports" + for port_path in /sys/class/net/*; do + [ ! -d "$port_path" ] && continue + local port=$(basename "$port_path") + local port_master=$(get_dsa_master "$port") + + if [ "$port_master" = "$master" ]; then + json_add_string "" "$port" + fi + done + json_close_array + + json_close_object + done + + json_close_array + + # Standalone interfaces (not DSA ports, not virtual) + json_add_array "standalone" + for iface_path in /sys/class/net/*; do + [ ! -d "$iface_path" ] && continue + local iface=$(basename "$iface_path") + local master=$(get_dsa_master "$iface") + + # Skip if has DSA master or is virtual + [ -n "$master" ] && continue + case "$iface" in + lo|br-*|docker*|veth*|tun*|tap*) continue ;; + esac + + # Check if it's a real ethernet device + [ -f "/sys/class/net/${iface}/device" ] || [ -d "/sys/class/net/${iface}/device" ] && { + json_add_string "" "$iface" + } + done + json_close_array + + json_close_object + json_dump +} + +# RPCD list handler +case "$1" in + list) + echo '{"get_switch_status":{},"get_interface_details":{"interface":"string"},"get_error_history":{"interface":"string","minutes":5},"clear_counters":{"interface":"string"},"get_topology":{}}' + ;; + call) + case "$2" in + get_switch_status) + get_switch_status + ;; + get_interface_details) + read -r input + iface=$(echo "$input" | jsonfilter -e '@.interface' 2>/dev/null) + get_interface_details "$iface" + ;; + get_error_history) + read -r input + iface=$(echo "$input" | jsonfilter -e '@.interface' 2>/dev/null) + minutes=$(echo "$input" | jsonfilter -e '@.minutes' 2>/dev/null) + get_error_history_method "$iface" "${minutes:-5}" + ;; + clear_counters) + read -r input + iface=$(echo "$input" | jsonfilter -e '@.interface' 2>/dev/null) + clear_counters "$iface" + ;; + get_topology) + get_topology + ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-secubox-netdiag/root/usr/share/luci/menu.d/luci-app-secubox-netdiag.json b/package/secubox/luci-app-secubox-netdiag/root/usr/share/luci/menu.d/luci-app-secubox-netdiag.json new file mode 100644 index 00000000..50e50c48 --- /dev/null +++ b/package/secubox/luci-app-secubox-netdiag/root/usr/share/luci/menu.d/luci-app-secubox-netdiag.json @@ -0,0 +1,14 @@ +{ + "admin/secubox/netdiag": { + "title": "Network Diagnostics", + "order": 45, + "action": { + "type": "view", + "path": "secubox-netdiag/overview" + }, + "depends": { + "acl": ["luci-app-secubox-netdiag"], + "uci": {} + } + } +} diff --git a/package/secubox/luci-app-secubox-netdiag/root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json b/package/secubox/luci-app-secubox-netdiag/root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json new file mode 100644 index 00000000..06482a89 --- /dev/null +++ b/package/secubox/luci-app-secubox-netdiag/root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json @@ -0,0 +1,22 @@ +{ + "luci-app-secubox-netdiag": { + "description": "Grant access to SecuBox Network Diagnostics", + "read": { + "ubus": { + "luci.secubox-netdiag": ["get_switch_status", "get_interface_details", "get_error_history", "get_topology"] + }, + "file": { + "/sys/class/net/*/statistics/*": ["read"], + "/sys/class/net/*/carrier": ["read"], + "/sys/class/net/*/operstate": ["read"], + "/sys/class/net/*/address": ["read"], + "/sys/class/net/*/mtu": ["read"] + } + }, + "write": { + "ubus": { + "luci.secubox-netdiag": ["clear_counters"] + } + } + } +} diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/apps.js b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/apps.js index 10e0c171..0b68a594 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/apps.js +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/apps.js @@ -19,7 +19,8 @@ var appCategories = [ description: 'SecuBox administration and app management', apps: [ { id: 'secubox-admin', name: 'SecuBox Admin', icon: '\ud83d\udcbb', path: 'admin/secubox/admin', desc: 'App catalog, updates, and system configuration' }, - { id: 'cyber-dashboard', name: 'Cyber Dashboard', icon: '\ud83d\udcca', path: 'admin/secubox/admin/cyber-dashboard', desc: 'Advanced analytics and insights' } + { id: 'cyber-dashboard', name: 'Cyber Dashboard', icon: '\ud83d\udcca', path: 'admin/secubox/admin/cyber-dashboard', desc: 'Advanced analytics and insights' }, + { id: 'netdiag', name: 'Network Diagnostics', icon: '\ud83d\udd0c', path: 'admin/secubox/netdiag', desc: 'DSA switch port statistics and error monitoring' } ] }, {