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 <noreply@anthropic.com>
This commit is contained in:
parent
af94288f61
commit
24dc62cb79
@ -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
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user