feat(streamlit): Add one-click deploy, expose, unpublish, and auth toggle
KISS workflow enhancements: - One-click deploy: Upload file auto-creates app + instance + starts - One-click expose: Creates HAProxy vhost + SSL cert in one action - One-click unpublish: Removes exposure and revokes certificate - Auth toggle: Enable/disable SecuBox user authentication per instance - Exposure status: Shows cert validity and expiry in instances table - Visual indicators: Green badge for exposed, orange for pending cert New RPCD methods: - upload_and_deploy: Upload + auto-create instance - emancipate_instance: One-click vhost + SSL setup - unpublish: Revoke exposure - set_auth_required: Toggle authentication requirement - get_exposure_status: Full exposure info with cert status Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
94812b465d
commit
397d7e2f74
@ -260,6 +260,40 @@ var callGetEmancipation = rpc.declare({
|
||||
expect: { result: {} }
|
||||
});
|
||||
|
||||
var callUploadAndDeploy = rpc.declare({
|
||||
object: 'luci.streamlit',
|
||||
method: 'upload_and_deploy',
|
||||
params: ['name', 'content', 'is_zip'],
|
||||
expect: { result: {} }
|
||||
});
|
||||
|
||||
var callEmancipateInstance = rpc.declare({
|
||||
object: 'luci.streamlit',
|
||||
method: 'emancipate_instance',
|
||||
params: ['id', 'domain'],
|
||||
expect: { result: {} }
|
||||
});
|
||||
|
||||
var callUnpublish = rpc.declare({
|
||||
object: 'luci.streamlit',
|
||||
method: 'unpublish',
|
||||
params: ['id'],
|
||||
expect: { result: {} }
|
||||
});
|
||||
|
||||
var callSetAuthRequired = rpc.declare({
|
||||
object: 'luci.streamlit',
|
||||
method: 'set_auth_required',
|
||||
params: ['id', 'auth_required'],
|
||||
expect: { result: {} }
|
||||
});
|
||||
|
||||
var callGetExposureStatus = rpc.declare({
|
||||
object: 'luci.streamlit',
|
||||
method: 'get_exposure_status',
|
||||
expect: { result: {} }
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
getStatus: function() {
|
||||
return callGetStatus();
|
||||
@ -463,6 +497,28 @@ return baseclass.extend({
|
||||
return callGetEmancipation(name);
|
||||
},
|
||||
|
||||
uploadAndDeploy: function(name, content, isZip) {
|
||||
return callUploadAndDeploy(name, content, isZip ? '1' : '0');
|
||||
},
|
||||
|
||||
emancipateInstance: function(id, domain) {
|
||||
return callEmancipateInstance(id, domain || '');
|
||||
},
|
||||
|
||||
unpublish: function(id) {
|
||||
return callUnpublish(id);
|
||||
},
|
||||
|
||||
setAuthRequired: function(id, authRequired) {
|
||||
return callSetAuthRequired(id, authRequired ? '1' : '0');
|
||||
},
|
||||
|
||||
getExposureStatus: function() {
|
||||
return callGetExposureStatus().then(function(res) {
|
||||
return res.instances || [];
|
||||
});
|
||||
},
|
||||
|
||||
getDashboardData: function() {
|
||||
var self = this;
|
||||
return Promise.all([
|
||||
|
||||
@ -9,6 +9,7 @@ return view.extend({
|
||||
status: {},
|
||||
apps: [],
|
||||
instances: [],
|
||||
exposure: [],
|
||||
|
||||
load: function() {
|
||||
return this.refresh();
|
||||
@ -19,11 +20,13 @@ return view.extend({
|
||||
return Promise.all([
|
||||
api.getStatus(),
|
||||
api.listApps(),
|
||||
api.listInstances()
|
||||
api.listInstances(),
|
||||
api.getExposureStatus().catch(function() { return []; })
|
||||
]).then(function(r) {
|
||||
self.status = r[0] || {};
|
||||
self.apps = (r[1] && r[1].apps) || [];
|
||||
self.instances = r[2] || [];
|
||||
self.exposure = r[3] || [];
|
||||
});
|
||||
},
|
||||
|
||||
@ -35,7 +38,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 apps - One-click deploy & expose')),
|
||||
|
||||
// Status Section
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
@ -56,28 +59,29 @@ return view.extend({
|
||||
E('tr', {}, [
|
||||
E('td', {}, _('Instances')),
|
||||
E('td', {}, this.instances.length.toString())
|
||||
]),
|
||||
E('tr', {}, [
|
||||
E('td', {}, _('Web URL')),
|
||||
E('td', {},
|
||||
s.web_url ? E('a', { 'href': s.web_url, 'target': '_blank' }, s.web_url) : '-')
|
||||
])
|
||||
]),
|
||||
E('div', { 'style': 'margin-top:12px' }, this.renderControls(installed, running))
|
||||
]),
|
||||
|
||||
// Instances Section
|
||||
// Instances Section - Main focus
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Instances')),
|
||||
E('div', { 'id': 'instances-table' }, this.renderInstancesTable()),
|
||||
E('div', { 'style': 'margin-top:12px' }, this.renderAddInstanceForm())
|
||||
E('h3', {}, _('Instances & Exposure')),
|
||||
E('div', { 'id': 'instances-table' }, this.renderInstancesTable())
|
||||
]),
|
||||
|
||||
// Apps Section
|
||||
// One-Click Deploy Section
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Apps')),
|
||||
E('div', { 'id': 'apps-table' }, this.renderAppsTable()),
|
||||
E('div', { 'style': 'margin-top:12px' }, this.renderUploadForm())
|
||||
E('h3', {}, _('One-Click Deploy')),
|
||||
E('p', { 'style': 'color:#666; margin-bottom:12px' },
|
||||
_('Upload a .py file to auto-create app + instance + start')),
|
||||
this.renderDeployForm()
|
||||
]),
|
||||
|
||||
// Apps Section (compact)
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Apps Library')),
|
||||
E('div', { 'id': 'apps-table' }, this.renderAppsTable())
|
||||
])
|
||||
]);
|
||||
|
||||
@ -116,54 +120,109 @@ return view.extend({
|
||||
'click': function() { self.doStart(); }
|
||||
}, _('Start')));
|
||||
}
|
||||
btns.push(E('button', {
|
||||
'class': 'cbi-button cbi-button-remove',
|
||||
'style': 'margin-left:8px',
|
||||
'click': function() { self.doUninstall(); }
|
||||
}, _('Uninstall')));
|
||||
}
|
||||
|
||||
return btns;
|
||||
},
|
||||
|
||||
getExposureInfo: function(id) {
|
||||
for (var i = 0; i < this.exposure.length; i++) {
|
||||
if (this.exposure[i].id === id)
|
||||
return this.exposure[i];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
renderInstancesTable: function() {
|
||||
var self = this;
|
||||
var instances = this.instances;
|
||||
|
||||
if (!instances.length) {
|
||||
return E('em', {}, _('No instances. Add one below.'));
|
||||
return E('em', {}, _('No instances. Use One-Click Deploy below.'));
|
||||
}
|
||||
|
||||
var rows = instances.map(function(inst) {
|
||||
var status = inst.enabled ?
|
||||
var exp = self.getExposureInfo(inst.id) || {};
|
||||
var isExposed = exp.emancipated;
|
||||
var certValid = exp.cert_valid;
|
||||
var authRequired = exp.auth_required;
|
||||
|
||||
// Status indicator
|
||||
var statusBadge;
|
||||
if (isExposed && certValid) {
|
||||
statusBadge = E('span', { 'style': 'background:#0a0; color:#fff; padding:2px 6px; border-radius:3px; font-size:11px' },
|
||||
'\u2713 ' + (exp.domain || 'Exposed'));
|
||||
} else if (isExposed) {
|
||||
statusBadge = E('span', { 'style': 'background:#f90; color:#fff; padding:2px 6px; border-radius:3px; font-size:11px' },
|
||||
'\u26A0 Cert pending');
|
||||
} else {
|
||||
statusBadge = E('span', { 'style': 'color:#999' }, _('Local only'));
|
||||
}
|
||||
|
||||
// Running indicator
|
||||
var runStatus = inst.enabled ?
|
||||
E('span', { 'style': 'color:#0a0' }, '\u25CF') :
|
||||
E('span', { 'style': 'color:#999' }, '\u25CB');
|
||||
|
||||
// Action buttons
|
||||
var actions = [];
|
||||
|
||||
// Enable/Disable
|
||||
if (inst.enabled) {
|
||||
actions.push(E('button', {
|
||||
'class': 'cbi-button',
|
||||
'title': _('Disable'),
|
||||
'click': function() { self.disableInstance(inst.id); }
|
||||
}, '\u23F8'));
|
||||
} else {
|
||||
actions.push(E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'title': _('Enable'),
|
||||
'click': function() { self.enableInstance(inst.id); }
|
||||
}, '\u25B6'));
|
||||
}
|
||||
|
||||
// Expose / Unpublish
|
||||
if (isExposed) {
|
||||
actions.push(E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'style': 'margin-left:4px',
|
||||
'title': _('Unpublish'),
|
||||
'click': function() { self.unpublishInstance(inst.id, exp.domain); }
|
||||
}, '\u2715'));
|
||||
} else {
|
||||
actions.push(E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'style': 'margin-left:4px',
|
||||
'title': _('Expose (one-click)'),
|
||||
'click': function() { self.exposeInstance(inst.id); }
|
||||
}, '\u2197'));
|
||||
}
|
||||
|
||||
// Auth toggle
|
||||
if (isExposed) {
|
||||
actions.push(E('button', {
|
||||
'class': authRequired ? 'cbi-button cbi-button-action' : 'cbi-button',
|
||||
'style': 'margin-left:4px',
|
||||
'title': authRequired ? _('Auth required - click to disable') : _('Public - click to require auth'),
|
||||
'click': function() { self.toggleAuth(inst.id, !authRequired); }
|
||||
}, authRequired ? '\uD83D\uDD12' : '\uD83D\uDD13'));
|
||||
}
|
||||
|
||||
// Delete
|
||||
actions.push(E('button', {
|
||||
'class': 'cbi-button cbi-button-remove',
|
||||
'style': 'margin-left:4px',
|
||||
'title': _('Delete'),
|
||||
'click': function() { self.deleteInstance(inst.id); }
|
||||
}, '\u2212'));
|
||||
|
||||
return E('tr', {}, [
|
||||
E('td', {}, [status, ' ', E('strong', {}, inst.id)]),
|
||||
E('td', {}, [runStatus, ' ', E('strong', {}, inst.id)]),
|
||||
E('td', {}, inst.app || '-'),
|
||||
E('td', {}, ':' + inst.port),
|
||||
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-positive',
|
||||
'style': 'margin-left:4px',
|
||||
'click': function() { self.emancipateInstance(inst.id, inst.app, inst.port); }
|
||||
}, _('Expose')),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-remove',
|
||||
'style': 'margin-left:4px',
|
||||
'click': function() { self.deleteInstance(inst.id); }
|
||||
}, _('Delete'))
|
||||
])
|
||||
E('td', {}, statusBadge),
|
||||
E('td', {}, actions)
|
||||
]);
|
||||
});
|
||||
|
||||
@ -172,35 +231,20 @@ return view.extend({
|
||||
E('th', { 'class': 'th' }, _('Instance')),
|
||||
E('th', { 'class': 'th' }, _('App')),
|
||||
E('th', { 'class': 'th' }, _('Port')),
|
||||
E('th', { 'class': 'th' }, _('Exposure')),
|
||||
E('th', { 'class': 'th' }, _('Actions'))
|
||||
])
|
||||
].concat(rows));
|
||||
},
|
||||
|
||||
renderAddInstanceForm: function() {
|
||||
renderDeployForm: function() {
|
||||
var self = this;
|
||||
var appOptions = [E('option', { 'value': '' }, _('-- Select App --'))];
|
||||
|
||||
this.apps.forEach(function(app) {
|
||||
var id = app.id || app.name;
|
||||
appOptions.push(E('option', { 'value': id }, app.name));
|
||||
});
|
||||
|
||||
// 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', { 'style': 'display:flex; gap:8px; align-items:center; flex-wrap:wrap' }, [
|
||||
E('input', { 'type': 'text', 'id': 'new-inst-id', 'class': 'cbi-input-text',
|
||||
'placeholder': _('ID'), 'style': 'width:100px' }),
|
||||
E('select', { 'id': 'new-inst-app', 'class': 'cbi-input-select' }, appOptions),
|
||||
E('input', { 'type': 'number', 'id': 'new-inst-port', 'class': 'cbi-input-text',
|
||||
'value': nextPort, 'min': '1024', 'max': '65535', 'style': 'width:80px' }),
|
||||
E('input', { 'type': 'file', 'id': 'deploy-file', 'accept': '.py,.zip' }),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() { self.addInstance(); }
|
||||
}, _('Add'))
|
||||
'click': function() { self.oneClickDeploy(); }
|
||||
}, _('Deploy & Create Instance'))
|
||||
]);
|
||||
},
|
||||
|
||||
@ -209,7 +253,7 @@ return view.extend({
|
||||
var apps = this.apps;
|
||||
|
||||
if (!apps.length) {
|
||||
return E('em', {}, _('No apps. Upload one below.'));
|
||||
return E('em', {}, _('No apps.'));
|
||||
}
|
||||
|
||||
var rows = apps.map(function(app) {
|
||||
@ -220,8 +264,8 @@ return view.extend({
|
||||
E('td', {}, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': function() { self.editApp(id); }
|
||||
}, _('Edit')),
|
||||
'click': function() { self.createInstanceFromApp(id); }
|
||||
}, _('+ Instance')),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-remove',
|
||||
'style': 'margin-left:4px',
|
||||
@ -240,17 +284,6 @@ return view.extend({
|
||||
].concat(rows));
|
||||
},
|
||||
|
||||
renderUploadForm: function() {
|
||||
var self = this;
|
||||
return E('div', { 'style': 'display:flex; gap:8px; align-items:center' }, [
|
||||
E('input', { 'type': 'file', 'id': 'upload-file', 'accept': '.py,.zip' }),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function() { self.uploadApp(); }
|
||||
}, _('Upload'))
|
||||
]);
|
||||
},
|
||||
|
||||
updateStatus: function() {
|
||||
var s = this.status;
|
||||
var statusEl = document.getElementById('svc-status');
|
||||
@ -284,7 +317,7 @@ return view.extend({
|
||||
var self = this;
|
||||
api.start().then(function(r) {
|
||||
if (r && r.success)
|
||||
ui.addNotification(null, E('p', {}, _('Service started')), 'info');
|
||||
ui.addNotification(null, E('p', {}, _('Started')), 'info');
|
||||
self.refresh();
|
||||
});
|
||||
},
|
||||
@ -293,7 +326,7 @@ return view.extend({
|
||||
var self = this;
|
||||
api.stop().then(function(r) {
|
||||
if (r && r.success)
|
||||
ui.addNotification(null, E('p', {}, _('Service stopped')), 'info');
|
||||
ui.addNotification(null, E('p', {}, _('Stopped')), 'info');
|
||||
self.refresh();
|
||||
});
|
||||
},
|
||||
@ -302,7 +335,7 @@ return view.extend({
|
||||
var self = this;
|
||||
api.restart().then(function(r) {
|
||||
if (r && r.success)
|
||||
ui.addNotification(null, E('p', {}, _('Service restarted')), 'info');
|
||||
ui.addNotification(null, E('p', {}, _('Restarted')), 'info');
|
||||
self.refresh();
|
||||
});
|
||||
},
|
||||
@ -317,7 +350,7 @@ return view.extend({
|
||||
self.pollInstall();
|
||||
} else {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, r.message || _('Install failed')), 'error');
|
||||
ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error');
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -342,68 +375,69 @@ return view.extend({
|
||||
setTimeout(check, 2000);
|
||||
},
|
||||
|
||||
doUninstall: function() {
|
||||
// One-click deploy
|
||||
oneClickDeploy: function() {
|
||||
var self = this;
|
||||
ui.showModal(_('Confirm'), [
|
||||
E('p', {}, _('Uninstall Streamlit?')),
|
||||
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.uninstall().then(function() {
|
||||
ui.addNotification(null, E('p', {}, _('Uninstalled')), 'info');
|
||||
self.refresh();
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}, _('Uninstall'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
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 || !app || !port) {
|
||||
ui.addNotification(null, E('p', {}, _('Fill all fields')), 'error');
|
||||
var fileInput = document.getElementById('deploy-file');
|
||||
if (!fileInput || !fileInput.files.length) {
|
||||
ui.addNotification(null, E('p', {}, _('Select a file')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
api.addInstance(id, id, app, port).then(function(r) {
|
||||
if (r && r.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Instance added')), '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')), 'error');
|
||||
var file = fileInput.files[0];
|
||||
var name = file.name.replace(/\.(py|zip)$/, '').replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
var isZip = file.name.endsWith('.zip');
|
||||
var reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
var bytes = new Uint8Array(e.target.result);
|
||||
var chunks = [];
|
||||
for (var i = 0; i < bytes.length; i += 8192) {
|
||||
chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192)));
|
||||
}
|
||||
});
|
||||
var content = btoa(chunks.join(''));
|
||||
|
||||
poll.stop();
|
||||
ui.showModal(_('Deploying'), [
|
||||
E('p', { 'class': 'spinning' }, _('Creating app + instance...'))
|
||||
]);
|
||||
|
||||
api.uploadAndDeploy(name, content, isZip).then(function(r) {
|
||||
poll.start();
|
||||
ui.hideModal();
|
||||
if (r && r.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Deployed: ') + name + ' on port ' + r.port), 'success');
|
||||
fileInput.value = '';
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
poll.start();
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, _('Error: ') + (err.message || err)), 'error');
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
},
|
||||
|
||||
// Instance actions
|
||||
enableInstance: function(id) {
|
||||
var self = this;
|
||||
api.enableInstance(id).then(function(r) {
|
||||
if (r && r.success) {
|
||||
if (r && r.success)
|
||||
ui.addNotification(null, E('p', {}, _('Enabled')), 'success');
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
}
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
});
|
||||
},
|
||||
|
||||
disableInstance: function(id) {
|
||||
var self = this;
|
||||
api.disableInstance(id).then(function(r) {
|
||||
if (r && r.success) {
|
||||
if (r && r.success)
|
||||
ui.addNotification(null, E('p', {}, _('Disabled')), 'success');
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
}
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
});
|
||||
},
|
||||
|
||||
@ -429,43 +463,77 @@ return view.extend({
|
||||
]);
|
||||
},
|
||||
|
||||
emancipateInstance: function(id, app, port) {
|
||||
// One-click expose
|
||||
exposeInstance: function(id) {
|
||||
var self = this;
|
||||
ui.showModal(_('Expose: ') + id, [
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Domain')),
|
||||
E('div', { 'class': 'cbi-value-field' },
|
||||
E('input', { 'type': 'text', 'id': 'expose-domain', 'class': 'cbi-input-text',
|
||||
'placeholder': id + '.gk2.secubox.in' }))
|
||||
]),
|
||||
E('div', { 'style': 'margin:12px 0; padding:8px; background:#f5f5f5; border-radius:4px' }, [
|
||||
E('small', {}, _('Creates DNS + HAProxy vhost + SSL certificate'))
|
||||
]),
|
||||
ui.showModal(_('Exposing...'), [
|
||||
E('p', { 'class': 'spinning' }, _('Creating vhost + SSL certificate...'))
|
||||
]);
|
||||
|
||||
api.emancipateInstance(id, '').then(function(r) {
|
||||
ui.hideModal();
|
||||
if (r && r.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Exposed at: ') + r.url), 'success');
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Unpublish
|
||||
unpublishInstance: function(id, domain) {
|
||||
var self = this;
|
||||
ui.showModal(_('Confirm'), [
|
||||
E('p', {}, _('Unpublish ') + domain + '?'),
|
||||
E('p', { 'style': 'color:#666' }, _('This removes the public URL and SSL certificate.')),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'style': 'margin-left:8px',
|
||||
'click': function() {
|
||||
var domain = document.getElementById('expose-domain').value.trim();
|
||||
ui.hideModal();
|
||||
ui.showModal(_('Exposing...'), [
|
||||
E('p', { 'class': 'spinning' }, _('Setting up exposure...'))
|
||||
]);
|
||||
api.emancipate(app, domain).then(function(r) {
|
||||
ui.hideModal();
|
||||
if (r && r.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Exposed at: ') + (r.domain || domain)), 'success');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error');
|
||||
}
|
||||
api.unpublish(id).then(function(r) {
|
||||
if (r && r.success)
|
||||
ui.addNotification(null, E('p', {}, _('Unpublished')), 'info');
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
});
|
||||
}
|
||||
}, _('Expose'))
|
||||
}, _('Unpublish'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
// Auth toggle
|
||||
toggleAuth: function(id, authRequired) {
|
||||
var self = this;
|
||||
api.setAuthRequired(id, authRequired).then(function(r) {
|
||||
if (r && r.success) {
|
||||
ui.addNotification(null, E('p', {},
|
||||
authRequired ? _('Auth required') : _('Public access')), 'info');
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Create instance from app
|
||||
createInstanceFromApp: function(appId) {
|
||||
var self = this;
|
||||
var usedPorts = this.instances.map(function(i) { return i.port; });
|
||||
var nextPort = 8501;
|
||||
while (usedPorts.indexOf(nextPort) !== -1) nextPort++;
|
||||
|
||||
api.addInstance(appId, appId, appId, nextPort).then(function(r) {
|
||||
if (r && r.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Instance created on port ') + nextPort), 'success');
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
deleteApp: function(name) {
|
||||
var self = this;
|
||||
ui.showModal(_('Confirm'), [
|
||||
@ -486,104 +554,5 @@ return view.extend({
|
||||
}, _('Delete'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
uploadApp: function() {
|
||||
var self = this;
|
||||
var fileInput = document.getElementById('upload-file');
|
||||
if (!fileInput || !fileInput.files.length) {
|
||||
ui.addNotification(null, E('p', {}, _('Select a file')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var file = fileInput.files[0];
|
||||
var name = file.name.replace(/\.(py|zip)$/, '').replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
var reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
var bytes = new Uint8Array(e.target.result);
|
||||
var chunks = [];
|
||||
for (var i = 0; i < bytes.length; i += 8192) {
|
||||
chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192)));
|
||||
}
|
||||
var content = btoa(chunks.join(''));
|
||||
|
||||
poll.stop();
|
||||
ui.showModal(_('Uploading'), [
|
||||
E('p', { 'class': 'spinning' }, _('Uploading ') + file.name + '...')
|
||||
]);
|
||||
|
||||
var uploadFn = content.length > 40000 ?
|
||||
api.chunkedUpload(name, content, file.name.endsWith('.zip')) :
|
||||
api.uploadApp(name, content);
|
||||
|
||||
uploadFn.then(function(r) {
|
||||
poll.start();
|
||||
ui.hideModal();
|
||||
if (r && r.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Uploaded: ') + name), 'success');
|
||||
fileInput.value = '';
|
||||
self.refresh().then(function() { self.updateStatus(); });
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
poll.start();
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, _('Error: ') + (err.message || err)), 'error');
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
},
|
||||
|
||||
editApp: function(id) {
|
||||
var self = this;
|
||||
ui.showModal(_('Loading...'), [
|
||||
E('p', { 'class': 'spinning' }, _('Loading source...'))
|
||||
]);
|
||||
|
||||
api.getSource(id).then(function(r) {
|
||||
if (!r || !r.content) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var source;
|
||||
try { source = atob(r.content); }
|
||||
catch (e) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, _('Decode error')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
ui.showModal(_('Edit: ') + id, [
|
||||
E('textarea', {
|
||||
'id': 'edit-source',
|
||||
'style': 'width:100%; height:300px; font-family:monospace; font-size:12px;',
|
||||
'spellcheck': 'false'
|
||||
}, source),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top:12px' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'style': 'margin-left:8px',
|
||||
'click': function() {
|
||||
var newSource = document.getElementById('edit-source').value;
|
||||
var encoded = btoa(newSource);
|
||||
ui.hideModal();
|
||||
api.saveSource(id, encoded).then(function(sr) {
|
||||
if (sr && sr.success)
|
||||
ui.addNotification(null, E('p', {}, _('Saved')), 'success');
|
||||
else
|
||||
ui.addNotification(null, E('p', {}, sr.message || _('Failed')), 'error');
|
||||
});
|
||||
}
|
||||
}, _('Save'))
|
||||
])
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1383,25 +1383,344 @@ get_emancipation() {
|
||||
fi
|
||||
|
||||
config_load "$CONFIG"
|
||||
local emancipated emancipated_at domain port
|
||||
local emancipated emancipated_at domain port auth_required
|
||||
emancipated=$(uci -q get "${CONFIG}.${name}.emancipated")
|
||||
emancipated_at=$(uci -q get "${CONFIG}.${name}.emancipated_at")
|
||||
domain=$(uci -q get "${CONFIG}.${name}.emancipated_domain")
|
||||
domain=$(uci -q get "${CONFIG}.${name}.domain")
|
||||
port=$(uci -q get "${CONFIG}.${name}.port")
|
||||
auth_required=$(uci -q get "${CONFIG}.${name}.auth_required")
|
||||
|
||||
# Also check instances
|
||||
if [ -z "$port" ]; then
|
||||
if [ -z "$port" ] || [ -z "$domain" ]; then
|
||||
for section in $(uci -q show "$CONFIG" | grep "\.app=" | grep "='${name}'" | cut -d. -f2); do
|
||||
port=$(uci -q get "${CONFIG}.${section}.port")
|
||||
[ -n "$port" ] && break
|
||||
[ -z "$port" ] && port=$(uci -q get "${CONFIG}.${section}.port")
|
||||
[ -z "$domain" ] && domain=$(uci -q get "${CONFIG}.${section}.domain")
|
||||
[ -z "$emancipated" ] && emancipated=$(uci -q get "${CONFIG}.${section}.emancipated")
|
||||
[ -z "$auth_required" ] && auth_required=$(uci -q get "${CONFIG}.${section}.auth_required")
|
||||
[ -n "$port" ] && [ -n "$domain" ] && break
|
||||
done
|
||||
fi
|
||||
|
||||
# Check certificate status if emancipated
|
||||
local cert_valid=0
|
||||
local cert_expires=""
|
||||
if [ "$emancipated" = "1" ] && [ -n "$domain" ]; then
|
||||
local cert_file="/srv/haproxy/certs/${domain}.pem"
|
||||
if [ -f "$cert_file" ]; then
|
||||
cert_valid=1
|
||||
cert_expires=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)
|
||||
fi
|
||||
fi
|
||||
|
||||
json_init_obj
|
||||
json_add_boolean "emancipated" "$( [ "$emancipated" = "1" ] && echo 1 || echo 0 )"
|
||||
json_add_string "emancipated_at" "$emancipated_at"
|
||||
json_add_string "domain" "$domain"
|
||||
json_add_int "port" "${port:-0}"
|
||||
json_add_boolean "auth_required" "$( [ "$auth_required" = "1" ] && echo 1 || echo 0 )"
|
||||
json_add_boolean "cert_valid" "$cert_valid"
|
||||
json_add_string "cert_expires" "$cert_expires"
|
||||
json_close_obj
|
||||
}
|
||||
|
||||
# One-click upload with auto instance creation
|
||||
upload_and_deploy() {
|
||||
local tmpinput="/tmp/rpcd_deploy_$$.json"
|
||||
cat > "$tmpinput"
|
||||
|
||||
local name content is_zip
|
||||
name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null)
|
||||
is_zip=$(jsonfilter -i "$tmpinput" -e '@.is_zip' 2>/dev/null)
|
||||
name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//')
|
||||
|
||||
if [ -z "$name" ]; then
|
||||
rm -f "$tmpinput"
|
||||
json_error "Missing name"
|
||||
return
|
||||
fi
|
||||
|
||||
local b64file="/tmp/rpcd_b64_$$.txt"
|
||||
jsonfilter -i "$tmpinput" -e '@.content' > "$b64file" 2>/dev/null
|
||||
rm -f "$tmpinput"
|
||||
|
||||
if [ ! -s "$b64file" ]; then
|
||||
rm -f "$b64file"
|
||||
json_error "Missing content"
|
||||
return
|
||||
fi
|
||||
|
||||
local data_path
|
||||
config_load "$CONFIG"
|
||||
config_get data_path main data_path "/srv/streamlit"
|
||||
mkdir -p "$data_path/apps"
|
||||
|
||||
local app_file="$data_path/apps/${name}.py"
|
||||
base64 -d < "$b64file" > "$app_file" 2>/dev/null
|
||||
local rc=$?
|
||||
rm -f "$b64file"
|
||||
|
||||
if [ $rc -ne 0 ] || [ ! -s "$app_file" ]; then
|
||||
rm -f "$app_file"
|
||||
json_error "Failed to decode content"
|
||||
return
|
||||
fi
|
||||
|
||||
# Register app in UCI
|
||||
uci set "${CONFIG}.${name}=app"
|
||||
uci set "${CONFIG}.${name}.name=$name"
|
||||
uci set "${CONFIG}.${name}.path=${name}.py"
|
||||
uci set "${CONFIG}.${name}.enabled=1"
|
||||
|
||||
# Find next available port
|
||||
local next_port=8501
|
||||
local used_ports=$(uci -q show "$CONFIG" | grep "\.port=" | cut -d= -f2 | tr -d "'" | sort -n)
|
||||
while echo "$used_ports" | grep -qw "$next_port"; do
|
||||
next_port=$((next_port + 1))
|
||||
done
|
||||
|
||||
# Create instance automatically
|
||||
uci set "${CONFIG}.${name}=instance"
|
||||
uci set "${CONFIG}.${name}.name=$name"
|
||||
uci set "${CONFIG}.${name}.app=$name"
|
||||
uci set "${CONFIG}.${name}.port=$next_port"
|
||||
uci set "${CONFIG}.${name}.enabled=1"
|
||||
uci set "${CONFIG}.${name}.autostart=1"
|
||||
uci commit "$CONFIG"
|
||||
|
||||
# Start the instance
|
||||
if lxc_running; then
|
||||
streamlitctl instance start "$name" >/dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
json_init_obj
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "App deployed with instance on port $next_port"
|
||||
json_add_string "name" "$name"
|
||||
json_add_int "port" "$next_port"
|
||||
json_close_obj
|
||||
}
|
||||
|
||||
# Unpublish/revoke emancipation
|
||||
unpublish() {
|
||||
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
|
||||
|
||||
config_load "$CONFIG"
|
||||
local domain
|
||||
domain=$(uci -q get "${CONFIG}.${id}.domain")
|
||||
|
||||
if [ -z "$domain" ]; then
|
||||
json_error "Instance not emancipated"
|
||||
return
|
||||
fi
|
||||
|
||||
# Remove HAProxy vhost
|
||||
local vhost_section=$(echo "$domain" | sed 's/\./_/g')
|
||||
uci -q delete "haproxy.${vhost_section}" 2>/dev/null
|
||||
|
||||
# Remove certificate entry
|
||||
uci -q delete "haproxy.cert_${vhost_section}" 2>/dev/null
|
||||
uci commit haproxy
|
||||
|
||||
# Regenerate and reload HAProxy
|
||||
haproxyctl generate >/dev/null 2>&1
|
||||
haproxyctl reload >/dev/null 2>&1
|
||||
|
||||
# Update instance UCI
|
||||
uci delete "${CONFIG}.${id}.emancipated" 2>/dev/null
|
||||
uci delete "${CONFIG}.${id}.emancipated_at" 2>/dev/null
|
||||
uci delete "${CONFIG}.${id}.domain" 2>/dev/null
|
||||
uci commit "$CONFIG"
|
||||
|
||||
json_init_obj
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Exposure revoked for $id"
|
||||
json_add_string "domain" "$domain"
|
||||
json_close_obj
|
||||
}
|
||||
|
||||
# Set authentication requirement
|
||||
set_auth_required() {
|
||||
read -r input
|
||||
local id auth_required
|
||||
id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null)
|
||||
auth_required=$(echo "$input" | jsonfilter -e '@.auth_required' 2>/dev/null)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
json_error "Missing instance id"
|
||||
return
|
||||
fi
|
||||
|
||||
config_load "$CONFIG"
|
||||
local domain
|
||||
domain=$(uci -q get "${CONFIG}.${id}.domain")
|
||||
|
||||
# Update UCI
|
||||
uci set "${CONFIG}.${id}.auth_required=$auth_required"
|
||||
uci commit "$CONFIG"
|
||||
|
||||
# Update HAProxy vhost if emancipated
|
||||
if [ -n "$domain" ]; then
|
||||
local vhost_section=$(echo "$domain" | sed 's/\./_/g')
|
||||
if [ "$auth_required" = "1" ]; then
|
||||
uci set "haproxy.${vhost_section}.auth_required=1"
|
||||
else
|
||||
uci -q delete "haproxy.${vhost_section}.auth_required"
|
||||
fi
|
||||
uci commit haproxy
|
||||
haproxyctl generate >/dev/null 2>&1
|
||||
haproxyctl reload >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
json_init_obj
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Auth requirement updated"
|
||||
json_add_boolean "auth_required" "$( [ "$auth_required" = "1" ] && echo 1 || echo 0 )"
|
||||
json_close_obj
|
||||
}
|
||||
|
||||
# One-click emancipate for instance
|
||||
emancipate_instance() {
|
||||
read -r input
|
||||
local id domain
|
||||
id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null)
|
||||
domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
json_error "Missing instance id"
|
||||
return
|
||||
fi
|
||||
|
||||
config_load "$CONFIG"
|
||||
local app port
|
||||
app=$(uci -q get "${CONFIG}.${id}.app")
|
||||
port=$(uci -q get "${CONFIG}.${id}.port")
|
||||
|
||||
if [ -z "$port" ]; then
|
||||
json_error "Instance has no port configured"
|
||||
return
|
||||
fi
|
||||
|
||||
# Auto-generate domain if not provided
|
||||
if [ -z "$domain" ]; then
|
||||
local wildcard_domain=$(uci -q get vortex.main.wildcard_domain)
|
||||
[ -z "$wildcard_domain" ] && wildcard_domain="gk2.secubox.in"
|
||||
domain="${id}.${wildcard_domain}"
|
||||
fi
|
||||
|
||||
# Create HAProxy vhost
|
||||
local vhost_section=$(echo "$domain" | sed 's/\./_/g')
|
||||
local backend_name="streamlit_${id}"
|
||||
|
||||
# Create backend
|
||||
uci set "haproxy.${backend_name}=backend"
|
||||
uci set "haproxy.${backend_name}.name=${backend_name}"
|
||||
uci set "haproxy.${backend_name}.mode=http"
|
||||
uci set "haproxy.${backend_name}.balance=roundrobin"
|
||||
uci set "haproxy.${backend_name}.enabled=1"
|
||||
|
||||
# Add server
|
||||
uci set "haproxy.${backend_name}_srv=server"
|
||||
uci set "haproxy.${backend_name}_srv.backend=${backend_name}"
|
||||
uci set "haproxy.${backend_name}_srv.name=streamlit"
|
||||
uci set "haproxy.${backend_name}_srv.address=127.0.0.1"
|
||||
uci set "haproxy.${backend_name}_srv.port=${port}"
|
||||
uci set "haproxy.${backend_name}_srv.weight=100"
|
||||
uci set "haproxy.${backend_name}_srv.check=1"
|
||||
uci set "haproxy.${backend_name}_srv.enabled=1"
|
||||
|
||||
# Create vhost
|
||||
uci set "haproxy.${vhost_section}=vhost"
|
||||
uci set "haproxy.${vhost_section}.domain=${domain}"
|
||||
uci set "haproxy.${vhost_section}.backend=${backend_name}"
|
||||
uci set "haproxy.${vhost_section}.ssl=1"
|
||||
uci set "haproxy.${vhost_section}.ssl_redirect=1"
|
||||
uci set "haproxy.${vhost_section}.acme=1"
|
||||
uci set "haproxy.${vhost_section}.waf_bypass=1"
|
||||
uci set "haproxy.${vhost_section}.enabled=1"
|
||||
|
||||
# Create certificate entry
|
||||
uci set "haproxy.cert_${vhost_section}=certificate"
|
||||
uci set "haproxy.cert_${vhost_section}.domain=${domain}"
|
||||
uci set "haproxy.cert_${vhost_section}.type=acme"
|
||||
uci set "haproxy.cert_${vhost_section}.enabled=1"
|
||||
|
||||
uci commit haproxy
|
||||
|
||||
# Regenerate and reload HAProxy
|
||||
haproxyctl generate >/dev/null 2>&1
|
||||
haproxyctl reload >/dev/null 2>&1
|
||||
|
||||
# Request certificate via ACME
|
||||
acmectl issue "$domain" >/dev/null 2>&1 &
|
||||
|
||||
# Update instance UCI
|
||||
uci set "${CONFIG}.${id}.emancipated=1"
|
||||
uci set "${CONFIG}.${id}.emancipated_at=$(date -Iseconds)"
|
||||
uci set "${CONFIG}.${id}.domain=${domain}"
|
||||
uci commit "$CONFIG"
|
||||
|
||||
json_init_obj
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Instance exposed at https://${domain}"
|
||||
json_add_string "domain" "$domain"
|
||||
json_add_string "url" "https://${domain}"
|
||||
json_add_int "port" "$port"
|
||||
json_close_obj
|
||||
}
|
||||
|
||||
# Get exposure status for all instances
|
||||
get_exposure_status() {
|
||||
json_init_obj
|
||||
json_add_array "instances"
|
||||
|
||||
config_load "$CONFIG"
|
||||
|
||||
_add_exposure_json() {
|
||||
local section="$1"
|
||||
local app port enabled domain emancipated auth_required
|
||||
|
||||
config_get app "$section" app ""
|
||||
config_get port "$section" port ""
|
||||
config_get enabled "$section" enabled "0"
|
||||
config_get domain "$section" domain ""
|
||||
config_get emancipated "$section" emancipated "0"
|
||||
config_get auth_required "$section" auth_required "0"
|
||||
|
||||
[ -z "$app" ] && return
|
||||
|
||||
local cert_valid=0
|
||||
local cert_expires=""
|
||||
if [ "$emancipated" = "1" ] && [ -n "$domain" ]; then
|
||||
local cert_file="/srv/haproxy/certs/${domain}.pem"
|
||||
if [ -f "$cert_file" ]; then
|
||||
cert_valid=1
|
||||
cert_expires=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)
|
||||
fi
|
||||
fi
|
||||
|
||||
json_add_object ""
|
||||
json_add_string "id" "$section"
|
||||
json_add_string "app" "$app"
|
||||
json_add_int "port" "$port"
|
||||
json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )"
|
||||
json_add_boolean "emancipated" "$( [ "$emancipated" = "1" ] && echo 1 || echo 0 )"
|
||||
json_add_string "domain" "$domain"
|
||||
json_add_boolean "auth_required" "$( [ "$auth_required" = "1" ] && echo 1 || echo 0 )"
|
||||
json_add_boolean "cert_valid" "$cert_valid"
|
||||
json_add_string "cert_expires" "$cert_expires"
|
||||
json_close_object
|
||||
}
|
||||
|
||||
config_foreach _add_exposure_json instance
|
||||
|
||||
json_close_array
|
||||
json_close_obj
|
||||
}
|
||||
|
||||
@ -1500,7 +1819,12 @@ case "$1" in
|
||||
"get_source": {"name": "str"},
|
||||
"save_source": {"name": "str", "content": "str"},
|
||||
"emancipate": {"name": "str", "domain": "str"},
|
||||
"get_emancipation": {"name": "str"}
|
||||
"get_emancipation": {"name": "str"},
|
||||
"upload_and_deploy": {"name": "str", "content": "str", "is_zip": "str"},
|
||||
"emancipate_instance": {"id": "str", "domain": "str"},
|
||||
"unpublish": {"id": "str"},
|
||||
"set_auth_required": {"id": "str", "auth_required": "str"},
|
||||
"get_exposure_status": {}
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
@ -1620,6 +1944,21 @@ case "$1" in
|
||||
get_emancipation)
|
||||
get_emancipation
|
||||
;;
|
||||
upload_and_deploy)
|
||||
upload_and_deploy
|
||||
;;
|
||||
emancipate_instance)
|
||||
emancipate_instance
|
||||
;;
|
||||
unpublish)
|
||||
unpublish
|
||||
;;
|
||||
set_auth_required)
|
||||
set_auth_required
|
||||
;;
|
||||
get_exposure_status)
|
||||
get_exposure_status
|
||||
;;
|
||||
*)
|
||||
json_error "Unknown method: $2"
|
||||
;;
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"list_apps", "get_app", "get_install_progress",
|
||||
"list_instances",
|
||||
"get_gitea_config", "gitea_list_repos",
|
||||
"get_source", "get_emancipation"
|
||||
"get_source", "get_emancipation", "get_exposure_status"
|
||||
]
|
||||
},
|
||||
"uci": ["streamlit"]
|
||||
@ -24,7 +24,8 @@
|
||||
"add_instance", "remove_instance", "enable_instance", "disable_instance",
|
||||
"rename_app", "rename_instance",
|
||||
"save_gitea_config", "gitea_clone", "gitea_pull",
|
||||
"save_source", "emancipate"
|
||||
"save_source", "emancipate",
|
||||
"upload_and_deploy", "emancipate_instance", "unpublish", "set_auth_required"
|
||||
]
|
||||
},
|
||||
"uci": ["streamlit"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user