From e07fec6cb4509ead3900a908edb2c26c9aaa5164 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sun, 1 Feb 2026 07:27:24 +0100 Subject: [PATCH] feat(streamlit): Add instances management and Gitea integration - Add Running Instances section with enable/disable/delete actions - Add Instance form to create new instances on different ports - Add Gitea clone functionality to pull apps from repositories - Add Gitea configuration section in Settings page - RPCD handler now supports: - get_gitea_config, save_gitea_config - gitea_clone, gitea_pull, gitea_list_repos - API module exports all new Gitea methods - Upload supports both .py files and .zip archives - Instance status shown with colored indicators Co-Authored-By: Claude Opus 4.5 --- package/secubox/luci-app-streamlit/Makefile | 4 +- .../luci-static/resources/streamlit/api.js | 53 +++ .../resources/view/streamlit/dashboard.js | 318 +++++++++++++++++- .../resources/view/streamlit/settings.js | 79 ++++- .../root/usr/libexec/rpcd/luci.streamlit | 146 +++++++- .../luci-app-streamlit_1.0.0-r11_all.ipk | Bin 0 -> 14749 bytes 6 files changed, 578 insertions(+), 22 deletions(-) create mode 100644 package/secubox/secubox-app-bonus/root/www/secubox-feed/luci-app-streamlit_1.0.0-r11_all.ipk diff --git a/package/secubox/luci-app-streamlit/Makefile b/package/secubox/luci-app-streamlit/Makefile index 7d72eb67..c4706355 100644 --- a/package/secubox/luci-app-streamlit/Makefile +++ b/package/secubox/luci-app-streamlit/Makefile @@ -8,14 +8,14 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-streamlit PKG_VERSION:=1.0.0 -PKG_RELEASE:=9 +PKG_RELEASE:=11 PKG_ARCH:=all PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=CyberMind LUCI_TITLE:=LuCI Streamlit Dashboard -LUCI_DESCRIPTION:=Modern dashboard for Streamlit Platform management on OpenWrt +LUCI_DESCRIPTION:=Multi-instance Streamlit management with Gitea integration LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +rpcd +rpcd-mod-luci +secubox-app-streamlit LUCI_PKGARCH:=all 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 efc57729..d752038f 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 @@ -164,6 +164,39 @@ var callDisableInstance = rpc.declare({ expect: { result: {} } }); +var callGetGiteaConfig = rpc.declare({ + object: 'luci.streamlit', + method: 'get_gitea_config', + expect: { result: {} } +}); + +var callSaveGiteaConfig = rpc.declare({ + object: 'luci.streamlit', + method: 'save_gitea_config', + params: ['enabled', 'url', 'user', 'token'], + expect: { result: {} } +}); + +var callGiteaClone = rpc.declare({ + object: 'luci.streamlit', + method: 'gitea_clone', + params: ['name', 'repo'], + expect: { result: {} } +}); + +var callGiteaPull = rpc.declare({ + object: 'luci.streamlit', + method: 'gitea_pull', + params: ['name'], + expect: { result: {} } +}); + +var callGiteaListRepos = rpc.declare({ + object: 'luci.streamlit', + method: 'gitea_list_repos', + expect: { result: {} } +}); + return baseclass.extend({ getStatus: function() { return callGetStatus(); @@ -282,6 +315,26 @@ return baseclass.extend({ return callDisableInstance(id); }, + getGiteaConfig: function() { + return callGetGiteaConfig(); + }, + + saveGiteaConfig: function(enabled, url, user, token) { + return callSaveGiteaConfig(enabled, url, user, token); + }, + + giteaClone: function(name, repo) { + return callGiteaClone(name, repo); + }, + + giteaPull: function(name) { + return callGiteaPull(name); + }, + + giteaListRepos: function() { + return callGiteaListRepos(); + }, + getDashboardData: function() { var self = this; return Promise.all([ diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js index 8a527372..2ccd5483 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js @@ -7,7 +7,9 @@ return view.extend({ status: {}, apps: [], + instances: [], activeApp: '', + giteaConfig: {}, load: function() { return this.refresh(); @@ -17,11 +19,15 @@ return view.extend({ var self = this; return Promise.all([ api.getStatus(), - api.listApps() + api.listApps(), + api.listInstances(), + api.getGiteaConfig().catch(function() { return {}; }) ]).then(function(r) { self.status = r[0] || {}; self.apps = (r[1] && r[1].apps) || []; self.activeApp = (r[1] && r[1].active_app) || ''; + self.instances = r[2] || []; + self.giteaConfig = r[3] || {}; }); }, @@ -33,7 +39,7 @@ return view.extend({ var view = E('div', { 'class': 'cbi-map' }, [ E('h2', {}, _('Streamlit Platform')), - E('div', { 'class': 'cbi-map-descr' }, _('Python data app hosting')), + E('div', { 'class': 'cbi-map-descr' }, _('Python data app hosting with multi-instance support')), // Status Section E('div', { 'class': 'cbi-section', 'id': 'status-section' }, [ @@ -59,6 +65,18 @@ return view.extend({ E('div', { 'class': 'cbi-page-actions' }, this.renderControls(installed, running)) ]), + // Instances Section + E('div', { 'class': 'cbi-section', 'id': 'instances-section' }, [ + E('h3', {}, _('Running Instances')), + E('div', { 'id': 'instances-table' }, this.renderInstancesTable()) + ]), + + // Add Instance Section + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Add Instance')), + this.renderAddInstanceForm() + ]), + // Apps Section E('div', { 'class': 'cbi-section', 'id': 'apps-section' }, [ E('h3', {}, _('Applications')), @@ -69,16 +87,23 @@ return view.extend({ E('div', { 'class': 'cbi-section' }, [ E('h3', {}, _('Upload App')), E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Python File')), + E('label', { 'class': 'cbi-value-title' }, _('File')), E('div', { 'class': 'cbi-value-field' }, [ - E('input', { 'type': 'file', 'id': 'upload-file', 'accept': '.py' }), + E('input', { 'type': 'file', 'id': 'upload-file', 'accept': '.py,.zip' }), E('button', { 'class': 'cbi-button cbi-button-action', 'style': 'margin-left: 8px', 'click': function() { self.uploadApp(); } }, _('Upload')) - ]) + ]), + E('div', { 'class': 'cbi-value-description' }, _('Accepts .py files or .zip archives')) ]) + ]), + + // Gitea Section + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Clone from Gitea')), + this.renderGiteaSection() ]) ]); @@ -127,6 +152,105 @@ return view.extend({ return btns; }, + renderInstancesTable: function() { + var self = this; + var instances = this.instances; + + if (!instances.length) { + return E('em', {}, _('No instances configured. Add one below.')); + } + + var rows = instances.map(function(inst) { + var statusIcon = inst.enabled ? + E('span', { 'style': 'color:#0a0' }, '\u25CF') : + E('span', { 'style': 'color:#999' }, '\u25CB'); + + return E('tr', {}, [ + E('td', {}, [ + E('strong', {}, inst.id), + inst.name && inst.name !== inst.id ? + E('span', { 'style': 'color:#666; margin-left:4px' }, '(' + inst.name + ')') : '' + ]), + E('td', {}, inst.app || '-'), + E('td', {}, ':' + inst.port), + E('td', { 'style': 'text-align:center' }, [ + statusIcon, + E('span', { 'style': 'margin-left:4px' }, inst.enabled ? _('Enabled') : _('Disabled')) + ]), + E('td', {}, [ + inst.enabled ? + E('button', { + 'class': 'cbi-button', + 'click': function() { self.disableInstance(inst.id); } + }, _('Disable')) : + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { self.enableInstance(inst.id); } + }, _('Enable')), + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'style': 'margin-left: 4px', + 'click': function() { self.deleteInstance(inst.id); } + }, _('Delete')) + ]) + ]); + }); + + return E('table', { 'class': 'table cbi-section-table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('ID')), + E('th', { 'class': 'th' }, _('App')), + E('th', { 'class': 'th' }, _('Port')), + E('th', { 'class': 'th', 'style': 'text-align:center' }, _('Status')), + E('th', { 'class': 'th' }, _('Actions')) + ]) + ].concat(rows)); + }, + + renderAddInstanceForm: function() { + var self = this; + var appOptions = [E('option', { 'value': '' }, _('-- Select App --'))]; + + this.apps.forEach(function(app) { + appOptions.push(E('option', { 'value': app.name }, app.name)); + }); + + // Calculate next available port + var usedPorts = this.instances.map(function(i) { return i.port; }); + var nextPort = 8501; + while (usedPorts.indexOf(nextPort) !== -1) { + nextPort++; + } + + return E('div', {}, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Instance ID')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { 'type': 'text', 'id': 'new-inst-id', 'class': 'cbi-input-text', 'placeholder': _('myapp') }) + ) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('App')), + E('div', { 'class': 'cbi-value-field' }, + E('select', { 'id': 'new-inst-app', 'class': 'cbi-input-select' }, appOptions) + ) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Port')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { 'type': 'number', 'id': 'new-inst-port', 'class': 'cbi-input-text', + 'value': nextPort, 'min': '1024', 'max': '65535' }) + ) + ]), + E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { self.addInstance(); } + }, _('Add Instance')) + ]) + ]); + }, + renderAppsTable: function() { var self = this; var apps = this.apps; @@ -166,11 +290,52 @@ return view.extend({ ].concat(rows)); }, + renderGiteaSection: function() { + var self = this; + var cfg = this.giteaConfig; + var enabled = cfg.enabled; + + return E('div', {}, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Status')), + E('div', { 'class': 'cbi-value-field' }, + enabled ? + E('span', { 'style': 'color:#0a0' }, _('Configured')) : + E('span', { 'style': 'color:#999' }, _('Not configured')) + ) + ]), + enabled ? E('div', {}, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Repository')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { 'type': 'text', 'id': 'gitea-repo', 'class': 'cbi-input-text', + 'placeholder': _('owner/repo or full URL') }) + ) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('App Name')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { 'type': 'text', 'id': 'gitea-name', 'class': 'cbi-input-text', + 'placeholder': _('myapp') }) + ) + ]), + E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { self.giteaClone(); } + }, _('Clone')) + ]) + ]) : E('div', { 'class': 'cbi-value-description' }, + _('Configure Gitea in Settings to enable cloning')) + ]); + }, + updateStatus: function() { var s = this.status; var statusEl = document.getElementById('svc-status'); var activeEl = document.getElementById('active-app'); var appsEl = document.getElementById('apps-table'); + var instancesEl = document.getElementById('instances-table'); if (statusEl) { statusEl.innerHTML = ''; @@ -191,6 +356,11 @@ return view.extend({ appsEl.innerHTML = ''; appsEl.appendChild(this.renderAppsTable()); } + + if (instancesEl) { + instancesEl.innerHTML = ''; + instancesEl.appendChild(this.renderInstancesTable()); + } }, doStart: function() { @@ -280,6 +450,91 @@ return view.extend({ ]); }, + addInstance: function() { + var self = this; + var id = document.getElementById('new-inst-id').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; + } + + api.addInstance(id, id, app, port).then(function(r) { + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('Instance added: ') + id), 'success'); + document.getElementById('new-inst-id').value = ''; + document.getElementById('new-inst-app').value = ''; + self.refresh().then(function() { self.updateStatus(); }); + } else { + ui.addNotification(null, E('p', {}, r.message || _('Failed to add instance')), 'error'); + } + }); + }, + + enableInstance: function(id) { + var self = this; + api.enableInstance(id).then(function(r) { + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('Instance enabled: ') + id), 'success'); + self.refresh().then(function() { self.updateStatus(); }); + } else { + ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); + } + }); + }, + + disableInstance: function(id) { + var self = this; + api.disableInstance(id).then(function(r) { + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('Instance disabled: ') + id), 'success'); + self.refresh().then(function() { self.updateStatus(); }); + } else { + ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); + } + }); + }, + + deleteInstance: function(id) { + var self = this; + ui.showModal(_('Confirm'), [ + E('p', {}, _('Delete instance: ') + id + '?'), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { + 'class': 'cbi-button cbi-button-negative', + 'style': 'margin-left: 8px', + 'click': function() { + ui.hideModal(); + api.removeInstance(id).then(function(r) { + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('Instance deleted')), 'info'); + } + self.refresh().then(function() { self.updateStatus(); }); + }); + } + }, _('Delete')) + ]) + ]); + }, + activateApp: function(name) { var self = this; api.setActiveApp(name).then(function(r) { @@ -288,7 +543,7 @@ return view.extend({ return api.restart(); } }).then(function() { - self.refresh(); + self.refresh().then(function() { self.updateStatus(); }); }); }, @@ -307,7 +562,7 @@ return view.extend({ if (r && r.success) { ui.addNotification(null, E('p', {}, _('App deleted')), 'info'); } - self.refresh(); + self.refresh().then(function() { self.updateStatus(); }); }); } }, _('Delete')) @@ -324,22 +579,63 @@ return view.extend({ } var file = fileInput.files[0]; - var name = file.name.replace(/\.py$/, ''); + var name = file.name.replace(/\.(py|zip)$/, ''); + var isZip = file.name.endsWith('.zip'); var reader = new FileReader(); reader.onload = function(e) { var content = btoa(e.target.result); - api.uploadApp(name, content).then(function(r) { + var uploadFn = isZip ? api.uploadZip(name, content, null) : api.uploadApp(name, content); + + uploadFn.then(function(r) { if (r && r.success) { ui.addNotification(null, E('p', {}, _('App uploaded: ') + name), 'success'); fileInput.value = ''; - self.refresh(); + self.refresh().then(function() { self.updateStatus(); }); } else { ui.addNotification(null, E('p', {}, r.message || _('Upload failed')), 'error'); } }); }; - reader.readAsText(file); + if (isZip) { + reader.readAsBinaryString(file); + } else { + reader.readAsText(file); + } + }, + + giteaClone: function() { + var self = this; + var repo = document.getElementById('gitea-repo').value.trim(); + var name = document.getElementById('gitea-name').value.trim(); + + if (!repo) { + ui.addNotification(null, E('p', {}, _('Please enter a repository')), 'error'); + return; + } + + if (!name) { + // Extract name from repo + name = repo.split('/').pop().replace(/\.git$/, ''); + } + + ui.showModal(_('Cloning'), [ + E('p', { 'class': 'spinning' }, _('Cloning ') + repo + '...') + ]); + + api.giteaClone(name, repo).then(function(r) { + ui.hideModal(); + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('Clone started: ') + name), 'success'); + document.getElementById('gitea-repo').value = ''; + document.getElementById('gitea-name').value = ''; + setTimeout(function() { + self.refresh().then(function() { self.updateStatus(); }); + }, 3000); + } else { + ui.addNotification(null, E('p', {}, r.message || _('Clone failed')), 'error'); + } + }); } }); diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/settings.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/settings.js index 04bb146f..c35dd1c5 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/settings.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/settings.js @@ -5,18 +5,24 @@ return view.extend({ config: {}, + giteaConfig: {}, load: function() { - return api.getConfig().then(function(c) { - return c || {}; + var self = this; + return Promise.all([ + api.getConfig().then(function(c) { return c || {}; }), + api.getGiteaConfig().catch(function() { return {}; }) + ]).then(function(r) { + self.config = r[0]; + self.giteaConfig = r[1]; }); }, - render: function(config) { + render: function() { var self = this; - this.config = config; - var main = config.main || {}; - var server = config.server || {}; + var main = this.config.main || {}; + var server = this.config.server || {}; + var gitea = this.giteaConfig || {}; return E('div', { 'class': 'cbi-map' }, [ E('h2', {}, _('Streamlit Settings')), @@ -113,7 +119,49 @@ return view.extend({ ]) ]), - // Save button + // Gitea Settings + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Gitea Integration')), + E('div', { 'class': 'cbi-section-descr' }, _('Configure Gitea to clone apps from repositories')), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Enabled')), + E('div', { 'class': 'cbi-value-field' }, + E('select', { 'id': 'cfg-gitea-enabled', 'class': 'cbi-input-select' }, [ + E('option', { 'value': '1', 'selected': gitea.enabled == true || gitea.enabled == '1' }, _('Yes')), + E('option', { 'value': '0', 'selected': !gitea.enabled || gitea.enabled == '0' }, _('No')) + ]) + ) + ]), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Gitea URL')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { 'type': 'text', 'id': 'cfg-gitea-url', 'class': 'cbi-input-text', + 'value': gitea.url || '', 'placeholder': 'http://192.168.255.1:3000' }) + ) + ]), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Username')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { 'type': 'text', 'id': 'cfg-gitea-user', 'class': 'cbi-input-text', + 'value': gitea.user || '', 'placeholder': 'admin' }) + ) + ]), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Access Token')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { 'type': 'password', 'id': 'cfg-gitea-token', 'class': 'cbi-input-text', + 'value': '', 'placeholder': gitea.has_token ? _('(token configured)') : _('Enter token') }), + E('div', { 'class': 'cbi-value-description' }, + _('Generate from Gitea: Settings > Applications > Generate Token')) + ]) + ]) + ]), + + // Save buttons E('div', { 'class': 'cbi-page-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', @@ -124,6 +172,9 @@ return view.extend({ }, save: function() { + var self = this; + + // Save main config var cfg = { enabled: document.getElementById('cfg-enabled').value, http_port: document.getElementById('cfg-port').value, @@ -137,12 +188,24 @@ return view.extend({ theme_primary_color: document.getElementById('cfg-color').value }; - api.saveConfig(cfg).then(function(r) { + // Save Gitea config + var giteaEnabled = document.getElementById('cfg-gitea-enabled').value; + var giteaUrl = document.getElementById('cfg-gitea-url').value; + var giteaUser = document.getElementById('cfg-gitea-user').value; + var giteaToken = document.getElementById('cfg-gitea-token').value; + + Promise.all([ + api.saveConfig(cfg), + api.saveGiteaConfig(giteaEnabled, giteaUrl, giteaUser, giteaToken || '') + ]).then(function(results) { + var r = results[0]; if (r && r.success) { ui.addNotification(null, E('p', {}, _('Settings saved')), 'info'); } else { ui.addNotification(null, E('p', {}, r.message || _('Save failed')), 'error'); } + }).catch(function(err) { + ui.addNotification(null, E('p', {}, _('Save failed: ') + 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 58fefb99..6c81dfab 100755 --- a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit +++ b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit @@ -712,6 +712,130 @@ upload_zip() { fi } +# Get Gitea config +get_gitea_config() { + config_load "$CONFIG" + local enabled url user token + + config_get enabled gitea enabled "0" + config_get url gitea url "" + config_get user gitea user "" + config_get token gitea token "" + + json_init_obj + json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" + json_add_string "url" "$url" + json_add_string "user" "$user" + json_add_boolean "has_token" "$( [ -n "$token" ] && echo 1 || echo 0 )" + json_close_obj +} + +# Save Gitea config +save_gitea_config() { + read -r input + local enabled url user token + + enabled=$(echo "$input" | jsonfilter -e '@.enabled' 2>/dev/null) + url=$(echo "$input" | jsonfilter -e '@.url' 2>/dev/null) + user=$(echo "$input" | jsonfilter -e '@.user' 2>/dev/null) + token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null) + + # Ensure gitea section exists + uci -q get "${CONFIG}.gitea" >/dev/null || uci set "${CONFIG}.gitea=gitea" + + [ -n "$enabled" ] && uci set "${CONFIG}.gitea.enabled=$enabled" + [ -n "$url" ] && uci set "${CONFIG}.gitea.url=$url" + [ -n "$user" ] && uci set "${CONFIG}.gitea.user=$user" + [ -n "$token" ] && uci set "${CONFIG}.gitea.token=$token" + + uci commit "$CONFIG" + + json_success "Gitea configuration saved" +} + +# Clone app from Gitea +gitea_clone() { + read -r input + local name repo + + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + repo=$(echo "$input" | jsonfilter -e '@.repo' 2>/dev/null) + + if [ -z "$name" ] || [ -z "$repo" ]; then + json_error "Missing name or repo" + return + fi + + # Run clone in background + /usr/sbin/streamlitctl gitea clone "$name" "$repo" >/var/log/streamlit-gitea.log 2>&1 & + + json_init_obj + json_add_boolean "success" 1 + json_add_string "message" "Cloning $repo to $name in background" + json_close_obj +} + +# Pull app from Gitea +gitea_pull() { + read -r input + local name + + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + + if [ -z "$name" ]; then + json_error "Missing app name" + return + fi + + # Run pull + /usr/sbin/streamlitctl gitea pull "$name" >/var/log/streamlit-gitea.log 2>&1 + + if [ $? -eq 0 ]; then + json_success "App updated from Gitea: $name" + else + json_error "Failed to pull app from Gitea" + fi +} + +# List Gitea repositories +gitea_list_repos() { + config_load "$CONFIG" + local enabled url user token + + config_get enabled gitea enabled "0" + config_get url gitea url "" + config_get user gitea user "" + config_get token gitea token "" + + if [ "$enabled" != "1" ] || [ -z "$url" ] || [ -z "$token" ]; then + json_error "Gitea not configured" + return + fi + + # Call Gitea API to list user repos + local api_url="${url}/api/v1/user/repos" + local response + + response=$(curl -s -H "Authorization: token $token" "$api_url" 2>/dev/null) + + if [ -z "$response" ]; then + json_error "Failed to connect to Gitea" + return + fi + + json_init_obj + json_add_array "repos" + + # Parse JSON response (simple extraction) + echo "$response" | jsonfilter -e '@[*].full_name' 2>/dev/null | while read -r repo; do + [ -z "$repo" ] && continue + json_add_string "" "$repo" + done + + json_close_array + json_close_obj +} + # Check install progress get_install_progress() { local log_file="/var/log/streamlit-install.log" @@ -793,7 +917,12 @@ case "$1" in "add_instance": {"id": "str", "name": "str", "app": "str", "port": 8501}, "remove_instance": {"id": "str"}, "enable_instance": {"id": "str"}, - "disable_instance": {"id": "str"} + "disable_instance": {"id": "str"}, + "get_gitea_config": {}, + "save_gitea_config": {"enabled": "str", "url": "str", "user": "str", "token": "str"}, + "gitea_clone": {"name": "str", "repo": "str"}, + "gitea_pull": {"name": "str"}, + "gitea_list_repos": {} } EOF ;; @@ -871,6 +1000,21 @@ case "$1" in disable_instance) disable_instance ;; + get_gitea_config) + get_gitea_config + ;; + save_gitea_config) + save_gitea_config + ;; + gitea_clone) + gitea_clone + ;; + gitea_pull) + gitea_pull + ;; + gitea_list_repos) + gitea_list_repos + ;; *) json_error "Unknown method: $2" ;; diff --git a/package/secubox/secubox-app-bonus/root/www/secubox-feed/luci-app-streamlit_1.0.0-r11_all.ipk b/package/secubox/secubox-app-bonus/root/www/secubox-feed/luci-app-streamlit_1.0.0-r11_all.ipk new file mode 100644 index 0000000000000000000000000000000000000000..3b072da0fb01347f0e482b89713bcd075700e09a GIT binary patch literal 14749 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