From 6fda6e220d2c2d933e367bf5950a24e78d55f0be Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Mon, 26 Jan 2026 12:43:17 +0100 Subject: [PATCH] feat(streamlit): Add LuCI instance management for multi-app support - Add Instances tab to LuCI Streamlit dashboard - RPCD backend: list/add/remove/enable/disable instances - API module: instance management methods - UI: Instance table with status, port, enable/disable/remove actions - Add Instance form with app selector and auto port assignment - Apply & Restart button to apply instance changes Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 7 +- package/secubox/luci-app-streamlit/Makefile | 2 +- .../luci-static/resources/streamlit/api.js | 56 +++ .../resources/view/streamlit/instances.js | 371 ++++++++++++++++++ .../root/usr/libexec/rpcd/luci.streamlit | 157 +++++++- .../share/luci/menu.d/luci-app-streamlit.json | 8 + .../files/usr/sbin/secubox-network-health | 177 +++++++++ 7 files changed, 775 insertions(+), 3 deletions(-) create mode 100644 package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/instances.js create mode 100644 package/secubox/secubox-base/files/usr/sbin/secubox-network-health diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c603d1a..aa3fff8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -117,7 +117,12 @@ "Bash(sfdisk:*)", "Bash(xzcat:*)", "Bash(head:*)", - "Bash(docker search:*)" + "Bash(docker search:*)", + "Bash(git merge:*)", + "Bash(gh run:*)", + "Bash(dig:*)", + "Bash(nslookup:*)", + "Bash(host:*)" ] } } diff --git a/package/secubox/luci-app-streamlit/Makefile b/package/secubox/luci-app-streamlit/Makefile index 374c189..b1762e3 100644 --- a/package/secubox/luci-app-streamlit/Makefile +++ b/package/secubox/luci-app-streamlit/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-streamlit PKG_VERSION:=1.0.0 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_ARCH:=all PKG_LICENSE:=Apache-2.0 diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js index 9a2ce94..1e900d0 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js @@ -116,6 +116,40 @@ var callGetInstallProgress = rpc.declare({ expect: { result: {} } }); +var callListInstances = rpc.declare({ + object: 'luci.streamlit', + method: 'list_instances', + expect: { result: {} } +}); + +var callAddInstance = rpc.declare({ + object: 'luci.streamlit', + method: 'add_instance', + params: ['id', 'name', 'app', 'port'], + expect: { result: {} } +}); + +var callRemoveInstance = rpc.declare({ + object: 'luci.streamlit', + method: 'remove_instance', + params: ['id'], + expect: { result: {} } +}); + +var callEnableInstance = rpc.declare({ + object: 'luci.streamlit', + method: 'enable_instance', + params: ['id'], + expect: { result: {} } +}); + +var callDisableInstance = rpc.declare({ + object: 'luci.streamlit', + method: 'disable_instance', + params: ['id'], + expect: { result: {} } +}); + return baseclass.extend({ getStatus: function() { return callGetStatus(); @@ -204,6 +238,28 @@ return baseclass.extend({ return callGetInstallProgress(); }, + listInstances: function() { + return callListInstances().then(function(res) { + return res.instances || []; + }); + }, + + addInstance: function(id, name, app, port) { + return callAddInstance(id, name, app, port); + }, + + removeInstance: function(id) { + return callRemoveInstance(id); + }, + + enableInstance: function(id) { + return callEnableInstance(id); + }, + + disableInstance: function(id) { + return callDisableInstance(id); + }, + getDashboardData: function() { var self = this; return Promise.all([ diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/instances.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/instances.js new file mode 100644 index 0000000..438259f --- /dev/null +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/instances.js @@ -0,0 +1,371 @@ +'use strict'; +'require view'; +'require ui'; +'require dom'; +'require poll'; +'require rpc'; +'require streamlit.api as api'; + +return view.extend({ + instancesData: [], + appsData: [], + + load: function() { + return this.refreshData(); + }, + + refreshData: function() { + var self = this; + return Promise.all([ + api.listInstances(), + api.listApps() + ]).then(function(results) { + self.instancesData = results[0] || []; + self.appsData = results[1] || {}; + return results; + }); + }, + + render: function() { + var self = this; + + var cssLink = E('link', { + 'rel': 'stylesheet', + 'type': 'text/css', + 'href': L.resource('streamlit/dashboard.css') + }); + + var container = E('div', { 'class': 'streamlit-dashboard' }, [ + cssLink, + this.renderHeader(), + this.renderInstancesCard(), + this.renderAddInstanceCard() + ]); + + poll.add(function() { + return self.refreshData().then(function() { + self.updateInstancesTable(); + }); + }, 10); + + return container; + }, + + renderHeader: function() { + return E('div', { 'class': 'st-header' }, [ + E('div', { 'class': 'st-header-content' }, [ + E('div', { 'class': 'st-logo' }, '\uD83D\uDCE6'), + E('div', {}, [ + E('h1', { 'class': 'st-title' }, _('INSTANCES')), + E('p', { 'class': 'st-subtitle' }, _('Manage multiple Streamlit app instances on different ports')) + ]) + ]) + ]); + }, + + renderInstancesCard: function() { + var self = this; + var instances = this.instancesData; + + var tableRows = instances.map(function(inst) { + return self.renderInstanceRow(inst); + }); + + if (instances.length === 0) { + tableRows = [ + E('tr', {}, [ + E('td', { 'colspan': '5', 'style': 'text-align: center; padding: 40px;' }, [ + E('div', { 'class': 'st-empty' }, [ + E('div', { 'class': 'st-empty-icon' }, '\uD83D\uDCE6'), + E('div', {}, _('No instances configured')) + ]) + ]) + ]) + ]; + } + + return E('div', { 'class': 'st-card', 'style': 'margin-bottom: 24px;' }, [ + E('div', { 'class': 'st-card-header' }, [ + E('div', { 'class': 'st-card-title' }, [ + E('span', {}, '\uD83D\uDD04'), + ' ' + _('Running Instances') + ]), + E('div', {}, [ + E('span', { 'style': 'color: #94a3b8; font-size: 13px;' }, + instances.length + ' ' + (instances.length === 1 ? _('instance') : _('instances'))), + E('button', { + 'class': 'st-btn st-btn-primary', + 'style': 'margin-left: 16px; padding: 6px 12px; font-size: 13px;', + 'click': function() { self.applyChanges(); } + }, ['\u21BB ', _('Apply & Restart')]) + ]) + ]), + E('div', { 'class': 'st-card-body' }, [ + E('table', { 'class': 'st-apps-table', 'id': 'instances-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, _('ID')), + E('th', {}, _('App')), + E('th', {}, _('Port')), + E('th', {}, _('Status')), + E('th', {}, _('Actions')) + ]) + ]), + E('tbody', { 'id': 'instances-tbody' }, tableRows) + ]) + ]) + ]); + }, + + renderInstanceRow: function(inst) { + var self = this; + var statusBadge = inst.enabled ? + E('span', { 'class': 'st-app-badge active' }, _('ENABLED')) : + E('span', { 'class': 'st-app-badge', 'style': 'background: #64748b;' }, _('DISABLED')); + + return E('tr', {}, [ + E('td', {}, [ + E('strong', {}, inst.id), + inst.name && inst.name !== inst.id ? E('span', { 'style': 'color: #94a3b8; margin-left: 8px;' }, '(' + inst.name + ')') : '' + ]), + E('td', {}, inst.app || '-'), + E('td', {}, [ + E('code', { 'style': 'background: #334155; padding: 2px 6px; border-radius: 4px;' }, ':' + inst.port) + ]), + E('td', {}, statusBadge), + E('td', {}, [ + E('div', { 'class': 'st-btn-group' }, [ + inst.enabled ? + E('button', { + 'class': 'st-btn', + 'style': 'padding: 5px 10px; font-size: 12px; background: #64748b;', + 'click': function() { self.handleDisable(inst.id); } + }, _('Disable')) : + E('button', { + 'class': 'st-btn st-btn-success', + 'style': 'padding: 5px 10px; font-size: 12px;', + 'click': function() { self.handleEnable(inst.id); } + }, _('Enable')), + E('button', { + 'class': 'st-btn st-btn-danger', + 'style': 'padding: 5px 10px; font-size: 12px;', + 'click': function() { self.handleRemove(inst.id); } + }, _('Remove')) + ]) + ]) + ]); + }, + + renderAddInstanceCard: function() { + var self = this; + var apps = (this.appsData.apps || []).map(function(app) { + return E('option', { 'value': app.name + '.py' }, app.name); + }); + + // Calculate next available port + var usedPorts = this.instancesData.map(function(i) { return i.port; }); + var nextPort = 8501; + while (usedPorts.indexOf(nextPort) !== -1) { + nextPort++; + } + + return E('div', { 'class': 'st-card' }, [ + E('div', { 'class': 'st-card-header' }, [ + E('div', { 'class': 'st-card-title' }, [ + E('span', {}, '\u2795'), + ' ' + _('Add Instance') + ]) + ]), + E('div', { 'class': 'st-card-body' }, [ + E('div', { 'style': 'display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;' }, [ + E('div', { 'class': 'st-form-group' }, [ + E('label', { 'class': 'st-form-label' }, _('Instance ID')), + E('input', { + 'type': 'text', + 'class': 'st-form-input', + 'id': 'new-inst-id', + 'placeholder': _('myapp') + }) + ]), + E('div', { 'class': 'st-form-group' }, [ + E('label', { 'class': 'st-form-label' }, _('Display Name')), + E('input', { + 'type': 'text', + 'class': 'st-form-input', + 'id': 'new-inst-name', + 'placeholder': _('My Application') + }) + ]), + E('div', { 'class': 'st-form-group' }, [ + E('label', { 'class': 'st-form-label' }, _('App File')), + E('select', { + 'class': 'st-form-input', + 'id': 'new-inst-app', + 'style': 'height: 42px;' + }, [ + E('option', { 'value': '' }, _('-- Select App --')), + apps.length > 0 ? apps : E('option', { 'disabled': true }, _('No apps available')) + ]) + ]), + E('div', { 'class': 'st-form-group' }, [ + E('label', { 'class': 'st-form-label' }, _('Port')), + E('input', { + 'type': 'number', + 'class': 'st-form-input', + 'id': 'new-inst-port', + 'value': nextPort, + 'min': '8501', + 'max': '9999' + }) + ]) + ]), + E('div', { 'style': 'margin-top: 16px;' }, [ + E('button', { + 'class': 'st-btn st-btn-success', + 'click': function() { self.handleAdd(); } + }, ['\u2795 ', _('Add Instance')]) + ]) + ]) + ]); + }, + + updateInstancesTable: function() { + var self = this; + var tbody = document.getElementById('instances-tbody'); + if (!tbody) return; + + tbody.innerHTML = ''; + + if (this.instancesData.length === 0) { + tbody.appendChild(E('tr', {}, [ + E('td', { 'colspan': '5', 'style': 'text-align: center; padding: 40px;' }, [ + E('div', { 'class': 'st-empty' }, [ + E('div', { 'class': 'st-empty-icon' }, '\uD83D\uDCE6'), + E('div', {}, _('No instances configured')) + ]) + ]) + ])); + return; + } + + this.instancesData.forEach(function(inst) { + tbody.appendChild(self.renderInstanceRow(inst)); + }); + }, + + handleAdd: function() { + var self = this; + var id = document.getElementById('new-inst-id').value.trim(); + var name = document.getElementById('new-inst-name').value.trim(); + var app = document.getElementById('new-inst-app').value; + var port = parseInt(document.getElementById('new-inst-port').value, 10); + + if (!id) { + ui.addNotification(null, E('p', {}, _('Please enter an instance ID')), 'error'); + return; + } + + if (!/^[a-zA-Z0-9_]+$/.test(id)) { + ui.addNotification(null, E('p', {}, _('ID can only contain letters, numbers, and underscores')), 'error'); + return; + } + + if (!app) { + ui.addNotification(null, E('p', {}, _('Please select an app')), 'error'); + return; + } + + if (!port || port < 1024 || port > 65535) { + ui.addNotification(null, E('p', {}, _('Please enter a valid port (1024-65535)')), 'error'); + return; + } + + if (!name) { + name = id; + } + + api.addInstance(id, name, app, port).then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('Instance added: ') + id), 'success'); + document.getElementById('new-inst-id').value = ''; + document.getElementById('new-inst-name').value = ''; + document.getElementById('new-inst-app').value = ''; + self.refreshData(); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Failed to add instance')), 'error'); + } + }).catch(function(err) { + ui.addNotification(null, E('p', {}, _('Error: ') + err.message), 'error'); + }); + }, + + handleEnable: function(id) { + var self = this; + api.enableInstance(id).then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('Instance enabled: ') + id), 'success'); + self.refreshData(); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Failed to enable instance')), 'error'); + } + }); + }, + + handleDisable: function(id) { + var self = this; + api.disableInstance(id).then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('Instance disabled: ') + id), 'success'); + self.refreshData(); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Failed to disable instance')), 'error'); + } + }); + }, + + handleRemove: function(id) { + var self = this; + + ui.showModal(_('Confirm Remove'), [ + E('p', {}, _('Are you sure you want to remove instance: ') + id + '?'), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + E('button', { + 'class': 'btn cbi-button-negative', + 'click': function() { + ui.hideModal(); + api.removeInstance(id).then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('Instance removed: ') + id), 'info'); + self.refreshData(); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Failed to remove instance')), 'error'); + } + }); + } + }, _('Remove')) + ]) + ]); + }, + + applyChanges: function() { + ui.showModal(_('Applying Changes'), [ + E('p', { 'class': 'spinning' }, _('Restarting Streamlit service...')) + ]); + + api.restart().then(function(result) { + ui.hideModal(); + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('Service restarted successfully')), 'success'); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Restart may have issues')), 'warning'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Error: ') + err.message), 'error'); + }); + } +}); diff --git a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit index 9d5114e..85af06d 100644 --- a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit +++ b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit @@ -470,6 +470,141 @@ upload_app() { fi } +# List instances +list_instances() { + json_init_obj + json_add_array "instances" + + config_load "$CONFIG" + + _add_instance_json() { + local section="$1" + local name app port enabled autostart inst_name + + config_get inst_name "$section" name "" + config_get app "$section" app "" + config_get port "$section" port "" + config_get enabled "$section" enabled "0" + config_get autostart "$section" autostart "0" + + [ -z "$app" ] && return + + json_add_object "" + json_add_string "id" "$section" + json_add_string "name" "$inst_name" + json_add_string "app" "$app" + json_add_int "port" "$port" + json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" + json_add_boolean "autostart" "$( [ "$autostart" = "1" ] && echo 1 || echo 0 )" + json_close_object + } + + config_foreach _add_instance_json instance + + json_close_array + json_close_obj +} + +# Add instance +add_instance() { + read -r input + local id name app port + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + app=$(echo "$input" | jsonfilter -e '@.app' 2>/dev/null) + port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null) + + if [ -z "$id" ] || [ -z "$app" ] || [ -z "$port" ]; then + json_error "Missing id, app, or port" + return + fi + + [ -z "$name" ] && name="$id" + + # Validate port number + if ! echo "$port" | grep -qE '^[0-9]+$'; then + json_error "Invalid port number" + return + fi + + # Check if instance already exists + local existing + existing=$(uci -q get "${CONFIG}.${id}") + if [ -n "$existing" ]; then + json_error "Instance $id already exists" + return + fi + + uci set "${CONFIG}.${id}=instance" + uci set "${CONFIG}.${id}.name=$name" + uci set "${CONFIG}.${id}.app=$app" + uci set "${CONFIG}.${id}.port=$port" + uci set "${CONFIG}.${id}.enabled=1" + uci set "${CONFIG}.${id}.autostart=1" + uci commit "$CONFIG" + + json_success "Instance added: $id" +} + +# Remove instance +remove_instance() { + read -r input + local id + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + + if [ -z "$id" ]; then + json_error "Missing instance id" + return + fi + + # Check if instance exists + local existing + existing=$(uci -q get "${CONFIG}.${id}") + if [ -z "$existing" ]; then + json_error "Instance $id not found" + return + fi + + uci delete "${CONFIG}.${id}" + uci commit "$CONFIG" + + json_success "Instance removed: $id" +} + +# Enable instance +enable_instance() { + read -r input + local id + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + + if [ -z "$id" ]; then + json_error "Missing instance id" + return + fi + + uci set "${CONFIG}.${id}.enabled=1" + uci commit "$CONFIG" + + json_success "Instance enabled: $id" +} + +# Disable instance +disable_instance() { + read -r input + local id + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + + if [ -z "$id" ]; then + json_error "Missing instance id" + return + fi + + uci set "${CONFIG}.${id}.enabled=0" + uci commit "$CONFIG" + + json_success "Instance disabled: $id" +} + # Check install progress get_install_progress() { local log_file="/var/log/streamlit-install.log" @@ -544,7 +679,12 @@ case "$1" in "remove_app": {"name": "str"}, "set_active_app": {"name": "str"}, "upload_app": {"name": "str", "content": "str"}, - "get_install_progress": {} + "get_install_progress": {}, + "list_instances": {}, + "add_instance": {"id": "str", "name": "str", "app": "str", "port": 8501}, + "remove_instance": {"id": "str"}, + "enable_instance": {"id": "str"}, + "disable_instance": {"id": "str"} } EOF ;; @@ -601,6 +741,21 @@ case "$1" in get_install_progress) get_install_progress ;; + list_instances) + list_instances + ;; + add_instance) + add_instance + ;; + remove_instance) + remove_instance + ;; + enable_instance) + enable_instance + ;; + disable_instance) + disable_instance + ;; *) json_error "Unknown method: $2" ;; diff --git a/package/secubox/luci-app-streamlit/root/usr/share/luci/menu.d/luci-app-streamlit.json b/package/secubox/luci-app-streamlit/root/usr/share/luci/menu.d/luci-app-streamlit.json index 6d9fc3a..ba874cb 100644 --- a/package/secubox/luci-app-streamlit/root/usr/share/luci/menu.d/luci-app-streamlit.json +++ b/package/secubox/luci-app-streamlit/root/usr/share/luci/menu.d/luci-app-streamlit.json @@ -26,6 +26,14 @@ "path": "streamlit/apps" } }, + "admin/services/streamlit/instances": { + "title": "Instances", + "order": 25, + "action": { + "type": "view", + "path": "streamlit/instances" + } + }, "admin/services/streamlit/logs": { "title": "Logs", "order": 30, diff --git a/package/secubox/secubox-base/files/usr/sbin/secubox-network-health b/package/secubox/secubox-base/files/usr/sbin/secubox-network-health new file mode 100644 index 0000000..a3b6025 --- /dev/null +++ b/package/secubox/secubox-base/files/usr/sbin/secubox-network-health @@ -0,0 +1,177 @@ +#!/bin/sh +# SecuBox Network Health Monitor +# Detects CRC errors, link flapping, and interface issues + +. /usr/share/libubox/jshn.sh + +DMESG_LINES=500 +FLAP_THRESHOLD=5 # Number of link changes to consider flapping +CRC_THRESHOLD=10 # CRC errors to consider problematic + +check_interface_health() { + local iface="$1" + local status="ok" + local issues="" + local crc_count=0 + local link_changes=0 + local current_state="unknown" + + # Get current link state + if [ -d "/sys/class/net/$iface" ]; then + current_state=$(cat /sys/class/net/$iface/operstate 2>/dev/null || echo "unknown") + fi + + # Count CRC errors from dmesg (last N lines) + crc_count=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface.*crc error" 2>/dev/null || echo 0) + + # Count link state changes from dmesg + link_up=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface: Link is Up" 2>/dev/null || echo 0) + link_down=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface: Link is Down" 2>/dev/null || echo 0) + link_changes=$((link_up + link_down)) + + # Determine status + if [ "$crc_count" -ge "$CRC_THRESHOLD" ]; then + status="critical" + issues="${issues}CRC errors detected ($crc_count); " + fi + + if [ "$link_changes" -ge "$FLAP_THRESHOLD" ]; then + if [ "$status" = "ok" ]; then + status="warning" + fi + issues="${issues}Link flapping detected ($link_changes changes); " + fi + + # Get interface stats + local rx_errors=0 tx_errors=0 rx_dropped=0 tx_dropped=0 + if [ -f "/sys/class/net/$iface/statistics/rx_errors" ]; then + rx_errors=$(cat /sys/class/net/$iface/statistics/rx_errors) + tx_errors=$(cat /sys/class/net/$iface/statistics/tx_errors) + rx_dropped=$(cat /sys/class/net/$iface/statistics/rx_dropped) + tx_dropped=$(cat /sys/class/net/$iface/statistics/tx_dropped) + fi + + if [ "$rx_errors" -gt 1000 ] || [ "$tx_errors" -gt 1000 ]; then + if [ "$status" = "ok" ]; then + status="warning" + fi + issues="${issues}High error count (rx:$rx_errors tx:$tx_errors); " + fi + + # Output JSON for this interface + json_add_object "$iface" + json_add_string "status" "$status" + json_add_string "state" "$current_state" + json_add_int "crc_errors" "$crc_count" + json_add_int "link_changes" "$link_changes" + json_add_int "rx_errors" "$rx_errors" + json_add_int "tx_errors" "$tx_errors" + json_add_int "rx_dropped" "$rx_dropped" + json_add_int "tx_dropped" "$tx_dropped" + json_add_string "issues" "${issues%%; }" + json_close_object +} + +get_network_health() { + json_init + json_add_string "timestamp" "$(date -Iseconds)" + json_add_object "interfaces" + + # Check all physical interfaces + for iface in /sys/class/net/eth* /sys/class/net/wan* /sys/class/net/lan*; do + [ -d "$iface" ] || continue + iface_name=$(basename "$iface") + # Skip virtual interfaces (must have device link) + [ -d "$iface/device" ] || continue + check_interface_health "$iface_name" + done + + json_close_object + + # Overall status + local overall="healthy" + local critical_count=0 + local warning_count=0 + + # Re-scan for overall status + for iface in /sys/class/net/eth* /sys/class/net/wan* /sys/class/net/lan*; do + [ -d "$iface" ] || continue + [ -d "$iface/device" ] || continue + iface_name=$(basename "$iface") + + crc=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface_name.*crc error" 2>/dev/null || echo 0) + if [ "$crc" -ge "$CRC_THRESHOLD" ]; then + critical_count=$((critical_count + 1)) + fi + + link_up=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface_name: Link is Up" 2>/dev/null || echo 0) + link_down=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface_name: Link is Down" 2>/dev/null || echo 0) + if [ $((link_up + link_down)) -ge "$FLAP_THRESHOLD" ]; then + warning_count=$((warning_count + 1)) + fi + done + + if [ "$critical_count" -gt 0 ]; then + overall="critical" + elif [ "$warning_count" -gt 0 ]; then + overall="warning" + fi + + json_add_string "overall" "$overall" + json_add_int "critical_interfaces" "$critical_count" + json_add_int "warning_interfaces" "$warning_count" + + # Add recommendations if issues found + if [ "$overall" != "healthy" ]; then + json_add_array "recommendations" + if [ "$critical_count" -gt 0 ]; then + json_add_string "" "Check/replace Ethernet cables on affected interfaces" + json_add_string "" "Try different port on switch/modem" + json_add_string "" "Inspect RJ45 connectors for damage" + fi + if [ "$warning_count" -gt 0 ]; then + json_add_string "" "Monitor link stability" + json_add_string "" "Check for EMI interference near cables" + fi + json_close_array + fi + + json_dump +} + +get_interface_detail() { + local iface="$1" + + if [ ! -d "/sys/class/net/$iface" ]; then + echo '{"error": "Interface not found"}' + return 1 + fi + + json_init + json_add_string "interface" "$iface" + json_add_string "state" "$(cat /sys/class/net/$iface/operstate 2>/dev/null)" + json_add_string "mac" "$(cat /sys/class/net/$iface/address 2>/dev/null)" + json_add_int "mtu" "$(cat /sys/class/net/$iface/mtu 2>/dev/null)" + + # Recent dmesg entries for this interface + json_add_array "recent_events" + dmesg | tail -n 100 | grep "$iface" | tail -n 10 | while read line; do + json_add_string "" "$line" + done + json_close_array + + json_dump +} + +case "$1" in + status|health) + get_network_health + ;; + detail) + get_interface_detail "$2" + ;; + *) + echo "Usage: $0 {status|health|detail }" + exit 1 + ;; +esac