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 <noreply@anthropic.com>
This commit is contained in:
parent
750dccd644
commit
6fda6e220d
@ -117,7 +117,12 @@
|
|||||||
"Bash(sfdisk:*)",
|
"Bash(sfdisk:*)",
|
||||||
"Bash(xzcat:*)",
|
"Bash(xzcat:*)",
|
||||||
"Bash(head:*)",
|
"Bash(head:*)",
|
||||||
"Bash(docker search:*)"
|
"Bash(docker search:*)",
|
||||||
|
"Bash(git merge:*)",
|
||||||
|
"Bash(gh run:*)",
|
||||||
|
"Bash(dig:*)",
|
||||||
|
"Bash(nslookup:*)",
|
||||||
|
"Bash(host:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=luci-app-streamlit
|
PKG_NAME:=luci-app-streamlit
|
||||||
PKG_VERSION:=1.0.0
|
PKG_VERSION:=1.0.0
|
||||||
PKG_RELEASE:=2
|
PKG_RELEASE:=3
|
||||||
PKG_ARCH:=all
|
PKG_ARCH:=all
|
||||||
|
|
||||||
PKG_LICENSE:=Apache-2.0
|
PKG_LICENSE:=Apache-2.0
|
||||||
|
|||||||
@ -116,6 +116,40 @@ var callGetInstallProgress = rpc.declare({
|
|||||||
expect: { result: {} }
|
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({
|
return baseclass.extend({
|
||||||
getStatus: function() {
|
getStatus: function() {
|
||||||
return callGetStatus();
|
return callGetStatus();
|
||||||
@ -204,6 +238,28 @@ return baseclass.extend({
|
|||||||
return callGetInstallProgress();
|
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() {
|
getDashboardData: function() {
|
||||||
var self = this;
|
var self = this;
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -470,6 +470,141 @@ upload_app() {
|
|||||||
fi
|
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
|
# Check install progress
|
||||||
get_install_progress() {
|
get_install_progress() {
|
||||||
local log_file="/var/log/streamlit-install.log"
|
local log_file="/var/log/streamlit-install.log"
|
||||||
@ -544,7 +679,12 @@ case "$1" in
|
|||||||
"remove_app": {"name": "str"},
|
"remove_app": {"name": "str"},
|
||||||
"set_active_app": {"name": "str"},
|
"set_active_app": {"name": "str"},
|
||||||
"upload_app": {"name": "str", "content": "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
|
EOF
|
||||||
;;
|
;;
|
||||||
@ -601,6 +741,21 @@ case "$1" in
|
|||||||
get_install_progress)
|
get_install_progress)
|
||||||
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"
|
json_error "Unknown method: $2"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@ -26,6 +26,14 @@
|
|||||||
"path": "streamlit/apps"
|
"path": "streamlit/apps"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"admin/services/streamlit/instances": {
|
||||||
|
"title": "Instances",
|
||||||
|
"order": 25,
|
||||||
|
"action": {
|
||||||
|
"type": "view",
|
||||||
|
"path": "streamlit/instances"
|
||||||
|
}
|
||||||
|
},
|
||||||
"admin/services/streamlit/logs": {
|
"admin/services/streamlit/logs": {
|
||||||
"title": "Logs",
|
"title": "Logs",
|
||||||
"order": 30,
|
"order": 30,
|
||||||
|
|||||||
@ -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 <interface>}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Loading…
Reference in New Issue
Block a user