From 24dc62cb79a06ea0f9448b64ddadb395dd54fb38 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Mon, 26 Jan 2026 11:41:47 +0100 Subject: [PATCH] feat(streamlit): Add Publish wizard for HAProxy vhost mapping - Add "Publish" button to deploy apps via HAProxy reverse proxy - Wizard configures: domain, SSL, ACME certificate - Creates HAProxy backend + server + vhost automatically - Shows PUBLISHED badge for apps with HAProxy integration - Bumped luci-app-streamlit to 1.0.0-r2 Co-Authored-By: Claude Opus 4.5 --- package/secubox/luci-app-streamlit/Makefile | 2 +- .../resources/view/streamlit/apps.js | 200 +++++++++++++++++- 2 files changed, 198 insertions(+), 4 deletions(-) diff --git a/package/secubox/luci-app-streamlit/Makefile b/package/secubox/luci-app-streamlit/Makefile index eed53a1c..374c1895 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:=1 +PKG_RELEASE:=2 PKG_ARCH:=all PKG_LICENSE:=Apache-2.0 diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/apps.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/apps.js index 1294b045..3c27f4d8 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/apps.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/apps.js @@ -3,11 +3,47 @@ 'require ui'; 'require dom'; 'require poll'; +'require rpc'; 'require streamlit.api as api'; +// HAProxy RPC calls for publishing +var haproxyCreateBackend = rpc.declare({ + object: 'luci.haproxy', + method: 'create_backend', + params: ['name', 'mode', 'balance', 'health_check', 'enabled'], + expect: {} +}); + +var haproxyCreateServer = rpc.declare({ + object: 'luci.haproxy', + method: 'create_server', + params: ['backend', 'name', 'address', 'port', 'weight', 'check', 'enabled'], + expect: {} +}); + +var haproxyCreateVhost = rpc.declare({ + object: 'luci.haproxy', + method: 'create_vhost', + params: ['domain', 'backend', 'ssl', 'ssl_redirect', 'acme', 'enabled'], + expect: {} +}); + +var haproxyListBackends = rpc.declare({ + object: 'luci.haproxy', + method: 'list_backends', + expect: { backends: [] } +}); + +var haproxyReload = rpc.declare({ + object: 'luci.haproxy', + method: 'reload', + expect: {} +}); + return view.extend({ appsData: null, statusData: null, + haproxyBackends: [], load: function() { return this.refreshData(); @@ -17,10 +53,13 @@ return view.extend({ var self = this; return Promise.all([ api.listApps(), - api.getStatus() + api.getStatus(), + haproxyListBackends().catch(function() { return { backends: [] }; }) ]).then(function(results) { self.appsData = results[0] || {}; self.statusData = results[1] || {}; + var backendResult = results[2] || {}; + self.haproxyBackends = Array.isArray(backendResult) ? backendResult : (backendResult.backends || []); return results; }); }, @@ -64,6 +103,13 @@ return view.extend({ ]); }, + isAppPublished: function(appName) { + var backendName = 'streamlit_' + appName; + return this.haproxyBackends.some(function(b) { + return b.name === backendName || b.id === backendName; + }); + }, + renderAppsCard: function() { var self = this; var apps = this.appsData.apps || []; @@ -71,10 +117,12 @@ return view.extend({ var tableRows = apps.map(function(app) { var isActive = app.active || app.name === activeApp; + var isPublished = self.isAppPublished(app.name); return E('tr', {}, [ E('td', { 'class': isActive ? 'st-app-active' : '' }, [ app.name, - isActive ? E('span', { 'class': 'st-app-badge active', 'style': 'margin-left: 8px' }, _('ACTIVE')) : '' + isActive ? E('span', { 'class': 'st-app-badge active', 'style': 'margin-left: 8px' }, _('ACTIVE')) : '', + isPublished ? E('span', { 'class': 'st-app-badge', 'style': 'margin-left: 8px; background: #059669; color: #fff;' }, _('PUBLISHED')) : '' ]), E('td', {}, app.path || '-'), E('td', {}, self.formatSize(app.size)), @@ -85,6 +133,11 @@ return view.extend({ 'style': 'padding: 5px 10px; font-size: 12px;', 'click': function() { self.handleActivate(app.name); } }, _('Activate')) : '', + !isPublished ? E('button', { + 'class': 'st-btn', + 'style': 'padding: 5px 10px; font-size: 12px; background: #7c3aed; color: #fff;', + 'click': function() { self.showPublishWizard(app.name); } + }, ['\uD83C\uDF10 ', _('Publish')]) : '', app.name !== 'hello' ? E('button', { 'class': 'st-btn st-btn-danger', 'style': 'padding: 5px 10px; font-size: 12px;', @@ -231,10 +284,12 @@ return view.extend({ apps.forEach(function(app) { var isActive = app.active || app.name === activeApp; + var isPublished = self.isAppPublished(app.name); tbody.appendChild(E('tr', {}, [ E('td', { 'class': isActive ? 'st-app-active' : '' }, [ app.name, - isActive ? E('span', { 'class': 'st-app-badge active', 'style': 'margin-left: 8px' }, _('ACTIVE')) : '' + isActive ? E('span', { 'class': 'st-app-badge active', 'style': 'margin-left: 8px' }, _('ACTIVE')) : '', + isPublished ? E('span', { 'class': 'st-app-badge', 'style': 'margin-left: 8px; background: #059669; color: #fff;' }, _('PUBLISHED')) : '' ]), E('td', {}, app.path || '-'), E('td', {}, self.formatSize(app.size)), @@ -245,6 +300,11 @@ return view.extend({ 'style': 'padding: 5px 10px; font-size: 12px;', 'click': function() { self.handleActivate(app.name); } }, _('Activate')) : '', + !isPublished ? E('button', { + 'class': 'st-btn', + 'style': 'padding: 5px 10px; font-size: 12px; background: #7c3aed; color: #fff;', + 'click': function() { self.showPublishWizard(app.name); } + }, ['\uD83C\uDF10 ', _('Publish')]) : '', app.name !== 'hello' ? E('button', { 'class': 'st-btn st-btn-danger', 'style': 'padding: 5px 10px; font-size: 12px;', @@ -365,5 +425,139 @@ return view.extend({ }, _('Remove')) ]) ]); + }, + + showPublishWizard: function(appName) { + var self = this; + var port = this.statusData.http_port || 8501; + var lanIp = '192.168.255.1'; + + // Try to get LAN IP from status + if (this.statusData.web_url) { + var match = this.statusData.web_url.match(/\/\/([^:\/]+)/); + if (match) lanIp = match[1]; + } + + ui.showModal(_('Publish App to Web'), [ + E('div', { 'style': 'margin-bottom: 16px;' }, [ + E('p', { 'style': 'margin-bottom: 12px;' }, [ + _('Configure HAProxy to expose '), + E('strong', {}, appName), + _(' via a custom domain.') + ]) + ]), + E('div', { 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Domain Name')), + E('input', { + 'type': 'text', + 'id': 'publish-domain', + 'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;', + 'placeholder': appName + '.example.com' + }), + E('small', { 'style': 'color: #64748b;' }, _('Enter the domain that will route to this app')) + ]), + E('div', { 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px;' }, [ + E('input', { + 'type': 'checkbox', + 'id': 'publish-ssl', + 'checked': true, + 'style': 'margin-right: 8px;' + }), + _('Enable SSL (HTTPS)') + ]) + ]), + E('div', { 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px;' }, [ + E('input', { + 'type': 'checkbox', + 'id': 'publish-acme', + 'checked': true, + 'style': 'margin-right: 8px;' + }), + _('Auto-request Let\'s Encrypt certificate') + ]) + ]), + E('div', { 'style': 'background: #334155; padding: 12px; border-radius: 4px; margin-bottom: 16px;' }, [ + E('p', { 'style': 'margin: 0; font-size: 13px;' }, [ + _('Backend: '), + E('code', {}, lanIp + ':' + port) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + E('button', { + 'class': 'btn cbi-button-positive', + 'style': 'margin-left: 8px;', + 'click': function() { + var domain = document.getElementById('publish-domain').value.trim(); + var ssl = document.getElementById('publish-ssl').checked; + var acme = document.getElementById('publish-acme').checked; + + if (!domain) { + ui.addNotification(null, E('p', {}, _('Please enter a domain name')), 'error'); + return; + } + + self.publishApp(appName, domain, lanIp, port, ssl, acme); + } + }, ['\uD83D\uDE80 ', _('Publish')]) + ]) + ]); + }, + + publishApp: function(appName, domain, backendIp, backendPort, ssl, acme) { + var self = this; + var backendName = 'streamlit_' + appName; + + ui.hideModal(); + ui.showModal(_('Publishing...'), [ + E('p', { 'class': 'spinning' }, _('Creating HAProxy configuration...')) + ]); + + // Step 1: Create backend + haproxyCreateBackend(backendName, 'http', 'roundrobin', 'httpchk', '1') + .then(function(result) { + if (result && result.error) { + throw new Error(result.error); + } + // Step 2: Create server + return haproxyCreateServer(backendName, appName, backendIp, backendPort.toString(), '100', '1', '1'); + }) + .then(function(result) { + if (result && result.error) { + throw new Error(result.error); + } + // Step 3: Create vhost + var sslFlag = ssl ? '1' : '0'; + var acmeFlag = acme ? '1' : '0'; + return haproxyCreateVhost(domain, backendName, sslFlag, sslFlag, acmeFlag, '1'); + }) + .then(function(result) { + if (result && result.error) { + throw new Error(result.error); + } + // Step 4: Reload HAProxy + return haproxyReload(); + }) + .then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', {}, [ + _('App published successfully! Access at: '), + E('a', { + 'href': (ssl ? 'https://' : 'http://') + domain, + 'target': '_blank', + 'style': 'color: #0ff;' + }, (ssl ? 'https://' : 'http://') + domain) + ]), 'success'); + self.refreshData(); + }) + .catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Publish failed: ') + (err.message || err)), 'error'); + }); } });