refactor(streamlit): KISS UI redesign
Simplify LuCI interface from 5 tabs to 2: - Dashboard: status, controls, apps list, upload (all-in-one) - Settings: configuration options Remove complex custom CSS, use standard LuCI styles. Deleted: overview.js, apps.js, instances.js, logs.js Added: dashboard.js (single-page dashboard) Updated: settings.js (simplified form), menu.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
280c6a08d9
commit
5317f37e7a
@ -1,897 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
'require view';
|
|
||||||
'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();
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshData: function() {
|
|
||||||
var self = this;
|
|
||||||
return Promise.all([
|
|
||||||
api.listApps(),
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
render: function() {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
// Inject CSS
|
|
||||||
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.renderAppsCard(),
|
|
||||||
this.renderUploadCard()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Poll for updates
|
|
||||||
poll.add(function() {
|
|
||||||
return self.refreshData().then(function() {
|
|
||||||
self.updateAppsTable();
|
|
||||||
});
|
|
||||||
}, 15);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHeader: function() {
|
|
||||||
return E('div', { 'class': 'st-header' }, [
|
|
||||||
E('div', { 'class': 'st-header-content' }, [
|
|
||||||
E('div', { 'class': 'st-logo' }, '\uD83D\uDCBB'),
|
|
||||||
E('div', {}, [
|
|
||||||
E('h1', { 'class': 'st-title' }, _('APP MANAGEMENT')),
|
|
||||||
E('p', { 'class': 'st-subtitle' }, _('Deploy and manage your Streamlit applications'))
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
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 || [];
|
|
||||||
var activeApp = this.appsData.active_app || 'hello';
|
|
||||||
|
|
||||||
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')) : '',
|
|
||||||
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)),
|
|
||||||
E('td', {}, [
|
|
||||||
E('div', { 'class': 'st-btn-group' }, [
|
|
||||||
!isActive ? E('button', {
|
|
||||||
'class': 'st-btn st-btn-primary',
|
|
||||||
'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;',
|
|
||||||
'click': function() { self.handleRemove(app.name); }
|
|
||||||
}, _('Remove')) : ''
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (apps.length === 0) {
|
|
||||||
tableRows = [
|
|
||||||
E('tr', {}, [
|
|
||||||
E('td', { 'colspan': '4', 'style': 'text-align: center; padding: 40px;' }, [
|
|
||||||
E('div', { 'class': 'st-empty' }, [
|
|
||||||
E('div', { 'class': 'st-empty-icon' }, '\uD83D\uDCE6'),
|
|
||||||
E('div', {}, _('No apps deployed yet'))
|
|
||||||
])
|
|
||||||
])
|
|
||||||
])
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
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\uDCCB'),
|
|
||||||
' ' + _('Deployed Apps')
|
|
||||||
]),
|
|
||||||
E('div', {}, [
|
|
||||||
E('span', { 'style': 'color: #94a3b8; font-size: 13px;' },
|
|
||||||
apps.length + ' ' + (apps.length === 1 ? _('app') : _('apps')))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-card-body' }, [
|
|
||||||
E('table', { 'class': 'st-apps-table', 'id': 'apps-table' }, [
|
|
||||||
E('thead', {}, [
|
|
||||||
E('tr', {}, [
|
|
||||||
E('th', {}, _('Name')),
|
|
||||||
E('th', {}, _('Path')),
|
|
||||||
E('th', {}, _('Size')),
|
|
||||||
E('th', {}, _('Actions'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('tbody', { 'id': 'apps-tbody' }, tableRows)
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderUploadCard: function() {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
return E('div', { 'class': 'st-card' }, [
|
|
||||||
E('div', { 'class': 'st-card-header' }, [
|
|
||||||
E('div', { 'class': 'st-card-title' }, [
|
|
||||||
E('span', {}, '\uD83D\uDCE4'),
|
|
||||||
' ' + _('Upload New App')
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-card-body' }, [
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('App Name')),
|
|
||||||
E('input', {
|
|
||||||
'type': 'text',
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'upload-name',
|
|
||||||
'placeholder': _('myapp (without .py extension)')
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
E('div', {
|
|
||||||
'class': 'st-upload-area',
|
|
||||||
'id': 'upload-area',
|
|
||||||
'click': function() { document.getElementById('upload-file').click(); },
|
|
||||||
'dragover': function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.classList.add('dragover');
|
|
||||||
},
|
|
||||||
'dragleave': function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.classList.remove('dragover');
|
|
||||||
},
|
|
||||||
'drop': function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.classList.remove('dragover');
|
|
||||||
var files = e.dataTransfer.files;
|
|
||||||
if (files.length > 0) {
|
|
||||||
self.handleFileSelect(files[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
E('div', { 'class': 'st-upload-icon' }, '\uD83D\uDCC1'),
|
|
||||||
E('div', { 'class': 'st-upload-text' }, [
|
|
||||||
E('p', {}, _('Drop your .py or .zip file here or click to browse')),
|
|
||||||
E('p', { 'style': 'font-size: 12px; color: #64748b;' }, _('Supported: Python (.py) or ZIP archive (.zip)'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('input', {
|
|
||||||
'type': 'file',
|
|
||||||
'id': 'upload-file',
|
|
||||||
'accept': '.py,.zip',
|
|
||||||
'style': 'display: none;',
|
|
||||||
'change': function(e) {
|
|
||||||
if (e.target.files.length > 0) {
|
|
||||||
self.handleFileSelect(e.target.files[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
E('div', { 'id': 'upload-status', 'style': 'margin-top: 16px; display: none;' })
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
formatSize: function(bytes) {
|
|
||||||
if (!bytes || bytes === '-') return '-';
|
|
||||||
var num = parseInt(bytes, 10);
|
|
||||||
if (isNaN(num)) return bytes;
|
|
||||||
if (num < 1024) return num + ' B';
|
|
||||||
if (num < 1024 * 1024) return (num / 1024).toFixed(1) + ' KB';
|
|
||||||
return (num / (1024 * 1024)).toFixed(1) + ' MB';
|
|
||||||
},
|
|
||||||
|
|
||||||
updateAppsTable: function() {
|
|
||||||
var self = this;
|
|
||||||
var tbody = document.getElementById('apps-tbody');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
var apps = this.appsData.apps || [];
|
|
||||||
var activeApp = this.appsData.active_app || 'hello';
|
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
if (apps.length === 0) {
|
|
||||||
tbody.appendChild(E('tr', {}, [
|
|
||||||
E('td', { 'colspan': '4', 'style': 'text-align: center; padding: 40px;' }, [
|
|
||||||
E('div', { 'class': 'st-empty' }, [
|
|
||||||
E('div', { 'class': 'st-empty-icon' }, '\uD83D\uDCE6'),
|
|
||||||
E('div', {}, _('No apps deployed yet'))
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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')) : '',
|
|
||||||
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)),
|
|
||||||
E('td', {}, [
|
|
||||||
E('div', { 'class': 'st-btn-group' }, [
|
|
||||||
!isActive ? E('button', {
|
|
||||||
'class': 'st-btn st-btn-primary',
|
|
||||||
'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;',
|
|
||||||
'click': function() { self.handleRemove(app.name); }
|
|
||||||
}, _('Remove')) : ''
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
handleFileSelect: function(file) {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
// Check for valid file types
|
|
||||||
var isPy = file.name.endsWith('.py');
|
|
||||||
var isZip = file.name.endsWith('.zip');
|
|
||||||
|
|
||||||
if (!isPy && !isZip) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Please select a Python (.py) or ZIP (.zip) file')), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nameInput = document.getElementById('upload-name');
|
|
||||||
if (!nameInput.value) {
|
|
||||||
// Auto-fill name from filename
|
|
||||||
nameInput.value = file.name.replace(/\.(py|zip)$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusDiv = document.getElementById('upload-status');
|
|
||||||
statusDiv.style.display = 'block';
|
|
||||||
statusDiv.innerHTML = '';
|
|
||||||
statusDiv.appendChild(E('p', { 'style': 'color: #0ff;' },
|
|
||||||
_('Selected: ') + file.name + ' (' + this.formatSize(file.size) + ')'));
|
|
||||||
|
|
||||||
if (isZip) {
|
|
||||||
// For ZIP files, show tree selection dialog
|
|
||||||
statusDiv.appendChild(E('button', {
|
|
||||||
'class': 'st-btn st-btn-success',
|
|
||||||
'style': 'margin-top: 10px;',
|
|
||||||
'click': function() { self.showZipTreeDialog(file); }
|
|
||||||
}, [E('span', {}, '\uD83D\uDCC2'), ' ' + _('Select Files from ZIP')]));
|
|
||||||
} else {
|
|
||||||
// For .py files, direct upload
|
|
||||||
statusDiv.appendChild(E('button', {
|
|
||||||
'class': 'st-btn st-btn-success',
|
|
||||||
'style': 'margin-top: 10px;',
|
|
||||||
'click': function() { self.uploadFile(file); }
|
|
||||||
}, [E('span', {}, '\uD83D\uDCE4'), ' ' + _('Upload App')]));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
pendingZipFile: null,
|
|
||||||
pendingZipContent: null,
|
|
||||||
zipTreeData: [],
|
|
||||||
|
|
||||||
showZipTreeDialog: function(file) {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
ui.showModal(_('Loading ZIP...'), [
|
|
||||||
E('p', { 'class': 'spinning' }, _('Reading ZIP archive contents...'))
|
|
||||||
]);
|
|
||||||
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = function(e) {
|
|
||||||
var bytes = new Uint8Array(e.target.result);
|
|
||||||
var binary = '';
|
|
||||||
for (var i = 0; i < bytes.byteLength; i++) {
|
|
||||||
binary += String.fromCharCode(bytes[i]);
|
|
||||||
}
|
|
||||||
var content = btoa(binary);
|
|
||||||
self.pendingZipContent = content;
|
|
||||||
self.pendingZipFile = file;
|
|
||||||
|
|
||||||
// Call backend to preview ZIP contents
|
|
||||||
api.previewZip(content).then(function(result) {
|
|
||||||
ui.hideModal();
|
|
||||||
if (result && result.files) {
|
|
||||||
self.zipTreeData = result.files;
|
|
||||||
self.renderZipTreeModal(result.files);
|
|
||||||
} else if (result && result.error) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Failed to read ZIP: ') + result.error), 'error');
|
|
||||||
} else {
|
|
||||||
// Fallback: parse ZIP client-side using JSZip-like approach
|
|
||||||
self.parseZipClientSide(content);
|
|
||||||
}
|
|
||||||
}).catch(function(err) {
|
|
||||||
ui.hideModal();
|
|
||||||
// Fallback: try client-side parsing
|
|
||||||
self.parseZipClientSide(content);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
reader.readAsArrayBuffer(file);
|
|
||||||
},
|
|
||||||
|
|
||||||
parseZipClientSide: function(content) {
|
|
||||||
var self = this;
|
|
||||||
// Basic ZIP parsing - extract file list from central directory
|
|
||||||
try {
|
|
||||||
var raw = atob(content);
|
|
||||||
var files = [];
|
|
||||||
var offset = 0;
|
|
||||||
|
|
||||||
// Find End of Central Directory
|
|
||||||
var eocdOffset = raw.lastIndexOf('PK\x05\x06');
|
|
||||||
if (eocdOffset === -1) {
|
|
||||||
throw new Error('Invalid ZIP file');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read number of entries
|
|
||||||
var numEntries = raw.charCodeAt(eocdOffset + 10) | (raw.charCodeAt(eocdOffset + 11) << 8);
|
|
||||||
var cdOffset = raw.charCodeAt(eocdOffset + 16) | (raw.charCodeAt(eocdOffset + 17) << 8) |
|
|
||||||
(raw.charCodeAt(eocdOffset + 18) << 16) | (raw.charCodeAt(eocdOffset + 19) << 24);
|
|
||||||
|
|
||||||
offset = cdOffset;
|
|
||||||
for (var i = 0; i < numEntries && offset < eocdOffset; i++) {
|
|
||||||
if (raw.substr(offset, 4) !== 'PK\x01\x02') break;
|
|
||||||
|
|
||||||
var nameLen = raw.charCodeAt(offset + 28) | (raw.charCodeAt(offset + 29) << 8);
|
|
||||||
var extraLen = raw.charCodeAt(offset + 30) | (raw.charCodeAt(offset + 31) << 8);
|
|
||||||
var commentLen = raw.charCodeAt(offset + 32) | (raw.charCodeAt(offset + 33) << 8);
|
|
||||||
var compSize = raw.charCodeAt(offset + 20) | (raw.charCodeAt(offset + 21) << 8) |
|
|
||||||
(raw.charCodeAt(offset + 22) << 16) | (raw.charCodeAt(offset + 23) << 24);
|
|
||||||
var uncompSize = raw.charCodeAt(offset + 24) | (raw.charCodeAt(offset + 25) << 8) |
|
|
||||||
(raw.charCodeAt(offset + 26) << 16) | (raw.charCodeAt(offset + 27) << 24);
|
|
||||||
|
|
||||||
var fileName = raw.substr(offset + 46, nameLen);
|
|
||||||
var isDir = fileName.endsWith('/');
|
|
||||||
|
|
||||||
files.push({
|
|
||||||
path: fileName,
|
|
||||||
size: uncompSize,
|
|
||||||
is_dir: isDir,
|
|
||||||
selected: fileName.endsWith('.py') || isDir
|
|
||||||
});
|
|
||||||
|
|
||||||
offset += 46 + nameLen + extraLen + commentLen;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.zipTreeData = files;
|
|
||||||
self.renderZipTreeModal(files);
|
|
||||||
} catch (err) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Failed to parse ZIP: ') + err.message), 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
renderZipTreeModal: function(files) {
|
|
||||||
var self = this;
|
|
||||||
var nameInput = document.getElementById('upload-name');
|
|
||||||
var appName = nameInput ? nameInput.value.trim() : 'app';
|
|
||||||
|
|
||||||
// Build tree structure
|
|
||||||
var tree = this.buildFileTree(files);
|
|
||||||
var treeHtml = this.renderTreeNode(tree, '', 0);
|
|
||||||
|
|
||||||
ui.showModal(_('Select Files to Deploy'), [
|
|
||||||
E('div', { 'style': 'margin-bottom: 16px;' }, [
|
|
||||||
E('p', {}, _('Select which files to extract from the ZIP archive:')),
|
|
||||||
E('div', { 'style': 'margin-top: 8px;' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn',
|
|
||||||
'style': 'padding: 4px 8px; font-size: 11px; margin-right: 8px;',
|
|
||||||
'click': function() { self.selectAllZipFiles(true); }
|
|
||||||
}, _('Select All')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn',
|
|
||||||
'style': 'padding: 4px 8px; font-size: 11px; margin-right: 8px;',
|
|
||||||
'click': function() { self.selectAllZipFiles(false); }
|
|
||||||
}, _('Deselect All')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn',
|
|
||||||
'style': 'padding: 4px 8px; font-size: 11px;',
|
|
||||||
'click': function() { self.selectPythonFiles(); }
|
|
||||||
}, _('Python Only'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', {
|
|
||||||
'id': 'zip-tree-container',
|
|
||||||
'style': 'max-height: 400px; overflow-y: auto; background: #0f172a; border: 1px solid #334155; border-radius: 4px; padding: 12px; font-family: monospace; font-size: 13px;'
|
|
||||||
}, treeHtml),
|
|
||||||
E('div', { 'style': 'margin-top: 16px; padding: 12px; background: #1e293b; border-radius: 4px;' }, [
|
|
||||||
E('span', { 'id': 'zip-selected-count', 'style': 'color: #94a3b8;' },
|
|
||||||
this.getSelectedCount() + ' ' + _('files selected'))
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'right', 'style': 'margin-top: 16px;' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn',
|
|
||||||
'click': function() {
|
|
||||||
ui.hideModal();
|
|
||||||
self.pendingZipFile = null;
|
|
||||||
self.pendingZipContent = null;
|
|
||||||
}
|
|
||||||
}, _('Cancel')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button-positive',
|
|
||||||
'style': 'margin-left: 8px;',
|
|
||||||
'click': function() { self.uploadSelectedZipFiles(); }
|
|
||||||
}, ['\uD83D\uDCE4 ', _('Deploy Selected')])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
buildFileTree: function(files) {
|
|
||||||
var root = { name: '', children: {}, files: [] };
|
|
||||||
|
|
||||||
files.forEach(function(file) {
|
|
||||||
var parts = file.path.split('/').filter(function(p) { return p; });
|
|
||||||
var current = root;
|
|
||||||
|
|
||||||
for (var i = 0; i < parts.length; i++) {
|
|
||||||
var part = parts[i];
|
|
||||||
var isLast = i === parts.length - 1;
|
|
||||||
|
|
||||||
if (isLast && !file.is_dir) {
|
|
||||||
current.files.push({
|
|
||||||
name: part,
|
|
||||||
path: file.path,
|
|
||||||
size: file.size,
|
|
||||||
selected: file.selected !== false
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (!current.children[part]) {
|
|
||||||
current.children[part] = { name: part, children: {}, files: [], path: file.path };
|
|
||||||
}
|
|
||||||
current = current.children[part];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return root;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderTreeNode: function(node, indent, level) {
|
|
||||||
var self = this;
|
|
||||||
var elements = [];
|
|
||||||
|
|
||||||
// Render subdirectories
|
|
||||||
var dirs = Object.keys(node.children).sort();
|
|
||||||
dirs.forEach(function(dirName) {
|
|
||||||
var dir = node.children[dirName];
|
|
||||||
var dirPath = dir.path || (indent ? indent + '/' + dirName : dirName);
|
|
||||||
|
|
||||||
elements.push(E('div', {
|
|
||||||
'class': 'zip-tree-dir',
|
|
||||||
'style': 'padding: 4px 0; padding-left: ' + (level * 16) + 'px;',
|
|
||||||
'data-path': dirPath
|
|
||||||
}, [
|
|
||||||
E('span', {
|
|
||||||
'class': 'zip-tree-toggle',
|
|
||||||
'style': 'cursor: pointer; color: #60a5fa; margin-right: 4px;',
|
|
||||||
'click': function(e) {
|
|
||||||
var next = this.parentNode.nextSibling;
|
|
||||||
while (next && next.classList && next.classList.contains('zip-tree-child-' + level)) {
|
|
||||||
next.style.display = next.style.display === 'none' ? 'block' : 'none';
|
|
||||||
next = next.nextSibling;
|
|
||||||
}
|
|
||||||
this.textContent = this.textContent === '\u25BC' ? '\u25B6' : '\u25BC';
|
|
||||||
}
|
|
||||||
}, '\u25BC'),
|
|
||||||
E('span', { 'style': 'color: #fbbf24;' }, '\uD83D\uDCC1 '),
|
|
||||||
E('span', { 'style': 'color: #f1f5f9;' }, dirName + '/')
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Render children
|
|
||||||
var childElements = self.renderTreeNode(dir, dirPath, level + 1);
|
|
||||||
childElements.forEach(function(el) {
|
|
||||||
el.classList.add('zip-tree-child-' + level);
|
|
||||||
elements.push(el);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render files
|
|
||||||
node.files.sort(function(a, b) { return a.name.localeCompare(b.name); });
|
|
||||||
node.files.forEach(function(file) {
|
|
||||||
var isPy = file.name.endsWith('.py');
|
|
||||||
var icon = isPy ? '\uD83D\uDC0D' : '\uD83D\uDCC4';
|
|
||||||
var color = isPy ? '#22c55e' : '#94a3b8';
|
|
||||||
|
|
||||||
elements.push(E('div', {
|
|
||||||
'class': 'zip-tree-file',
|
|
||||||
'style': 'padding: 4px 0; padding-left: ' + (level * 16) + 'px;'
|
|
||||||
}, [
|
|
||||||
E('label', { 'style': 'cursor: pointer; display: flex; align-items: center;' }, [
|
|
||||||
E('input', {
|
|
||||||
'type': 'checkbox',
|
|
||||||
'class': 'zip-file-checkbox',
|
|
||||||
'data-path': file.path,
|
|
||||||
'checked': file.selected,
|
|
||||||
'style': 'margin-right: 8px;',
|
|
||||||
'change': function() { self.updateSelectedCount(); }
|
|
||||||
}),
|
|
||||||
E('span', { 'style': 'margin-right: 4px;' }, icon),
|
|
||||||
E('span', { 'style': 'color: ' + color + ';' }, file.name),
|
|
||||||
E('span', { 'style': 'color: #64748b; margin-left: 8px; font-size: 11px;' },
|
|
||||||
'(' + self.formatSize(file.size) + ')')
|
|
||||||
])
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
|
|
||||||
return elements;
|
|
||||||
},
|
|
||||||
|
|
||||||
selectAllZipFiles: function(select) {
|
|
||||||
var checkboxes = document.querySelectorAll('.zip-file-checkbox');
|
|
||||||
checkboxes.forEach(function(cb) { cb.checked = select; });
|
|
||||||
this.updateSelectedCount();
|
|
||||||
},
|
|
||||||
|
|
||||||
selectPythonFiles: function() {
|
|
||||||
var checkboxes = document.querySelectorAll('.zip-file-checkbox');
|
|
||||||
checkboxes.forEach(function(cb) {
|
|
||||||
cb.checked = cb.dataset.path.endsWith('.py');
|
|
||||||
});
|
|
||||||
this.updateSelectedCount();
|
|
||||||
},
|
|
||||||
|
|
||||||
getSelectedCount: function() {
|
|
||||||
var checkboxes = document.querySelectorAll('.zip-file-checkbox:checked');
|
|
||||||
return checkboxes ? checkboxes.length : 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
updateSelectedCount: function() {
|
|
||||||
var countEl = document.getElementById('zip-selected-count');
|
|
||||||
if (countEl) {
|
|
||||||
countEl.textContent = this.getSelectedCount() + ' ' + _('files selected');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
uploadSelectedZipFiles: function() {
|
|
||||||
var self = this;
|
|
||||||
var nameInput = document.getElementById('upload-name');
|
|
||||||
var name = nameInput ? nameInput.value.trim() : '';
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Please enter an app name')), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect selected files
|
|
||||||
var selectedFiles = [];
|
|
||||||
var checkboxes = document.querySelectorAll('.zip-file-checkbox:checked');
|
|
||||||
checkboxes.forEach(function(cb) {
|
|
||||||
selectedFiles.push(cb.dataset.path);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Please select at least one file')), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.hideModal();
|
|
||||||
ui.showModal(_('Deploying...'), [
|
|
||||||
E('p', { 'class': 'spinning' }, _('Extracting and deploying selected files...'))
|
|
||||||
]);
|
|
||||||
|
|
||||||
api.uploadZip(name, self.pendingZipContent, selectedFiles).then(function(result) {
|
|
||||||
ui.hideModal();
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('App deployed successfully: ') + name), 'success');
|
|
||||||
nameInput.value = '';
|
|
||||||
document.getElementById('upload-status').style.display = 'none';
|
|
||||||
self.pendingZipFile = null;
|
|
||||||
self.pendingZipContent = null;
|
|
||||||
self.refreshData();
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || result.error || _('Deploy failed')), 'error');
|
|
||||||
}
|
|
||||||
}).catch(function(err) {
|
|
||||||
ui.hideModal();
|
|
||||||
ui.addNotification(null, E('p', {}, _('Deploy failed: ') + err.message), 'error');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
uploadFile: function(file) {
|
|
||||||
var self = this;
|
|
||||||
var nameInput = document.getElementById('upload-name');
|
|
||||||
var name = nameInput.value.trim();
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Please enter an app name')), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate name (alphanumeric and underscore only)
|
|
||||||
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('App name can only contain letters, numbers, and underscores')), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = function(e) {
|
|
||||||
// Convert ArrayBuffer to base64 (handles UTF-8 correctly)
|
|
||||||
var bytes = new Uint8Array(e.target.result);
|
|
||||||
var binary = '';
|
|
||||||
for (var i = 0; i < bytes.byteLength; i++) {
|
|
||||||
binary += String.fromCharCode(bytes[i]);
|
|
||||||
}
|
|
||||||
var content = btoa(binary);
|
|
||||||
|
|
||||||
api.uploadApp(name, content).then(function(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('App uploaded successfully: ') + name), 'success');
|
|
||||||
nameInput.value = '';
|
|
||||||
document.getElementById('upload-status').style.display = 'none';
|
|
||||||
self.refreshData();
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Upload failed')), 'error');
|
|
||||||
}
|
|
||||||
}).catch(function(err) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Upload failed: ') + err.message), 'error');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
reader.readAsArrayBuffer(file);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleActivate: function(name) {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
api.setActiveApp(name).then(function(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Active app set to: ') + name), 'success');
|
|
||||||
self.refreshData();
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Failed to activate app')), 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
handleRemove: function(name) {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
ui.showModal(_('Confirm Remove'), [
|
|
||||||
E('p', {}, _('Are you sure you want to remove the app: ') + name + '?'),
|
|
||||||
E('div', { 'class': 'right' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn',
|
|
||||||
'click': ui.hideModal
|
|
||||||
}, _('Cancel')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button-negative',
|
|
||||||
'click': function() {
|
|
||||||
ui.hideModal();
|
|
||||||
api.removeApp(name).then(function(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('App removed: ') + name), 'info');
|
|
||||||
self.refreshData();
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Failed to remove app')), 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, _('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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -0,0 +1,345 @@
|
|||||||
|
'use strict';
|
||||||
|
'require view';
|
||||||
|
'require ui';
|
||||||
|
'require poll';
|
||||||
|
'require streamlit.api as api';
|
||||||
|
|
||||||
|
return view.extend({
|
||||||
|
status: {},
|
||||||
|
apps: [],
|
||||||
|
activeApp: '',
|
||||||
|
|
||||||
|
load: function() {
|
||||||
|
return this.refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh: function() {
|
||||||
|
var self = this;
|
||||||
|
return Promise.all([
|
||||||
|
api.getStatus(),
|
||||||
|
api.listApps()
|
||||||
|
]).then(function(r) {
|
||||||
|
self.status = r[0] || {};
|
||||||
|
self.apps = (r[1] && r[1].apps) || [];
|
||||||
|
self.activeApp = (r[1] && r[1].active_app) || '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function() {
|
||||||
|
var self = this;
|
||||||
|
var s = this.status;
|
||||||
|
var running = s.running;
|
||||||
|
var installed = s.installed;
|
||||||
|
|
||||||
|
var view = E('div', { 'class': 'cbi-map' }, [
|
||||||
|
E('h2', {}, _('Streamlit Platform')),
|
||||||
|
E('div', { 'class': 'cbi-map-descr' }, _('Python data app hosting')),
|
||||||
|
|
||||||
|
// Status Section
|
||||||
|
E('div', { 'class': 'cbi-section', 'id': 'status-section' }, [
|
||||||
|
E('h3', {}, _('Service Status')),
|
||||||
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
|
E('label', { 'class': 'cbi-value-title' }, _('Status')),
|
||||||
|
E('div', { 'class': 'cbi-value-field', 'id': 'svc-status' },
|
||||||
|
!installed ? E('em', { 'style': 'color:#999' }, _('Not installed')) :
|
||||||
|
running ? E('span', { 'style': 'color:#0a0' }, _('Running')) :
|
||||||
|
E('span', { 'style': 'color:#a00' }, _('Stopped'))
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
|
E('label', { 'class': 'cbi-value-title' }, _('Active App')),
|
||||||
|
E('div', { 'class': 'cbi-value-field', 'id': 'active-app' },
|
||||||
|
this.activeApp || E('em', {}, '-'))
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
|
E('label', { 'class': 'cbi-value-title' }, _('Web URL')),
|
||||||
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
|
s.web_url ? E('a', { 'href': s.web_url, 'target': '_blank' }, s.web_url) : '-')
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'cbi-page-actions' }, this.renderControls(installed, running))
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Apps Section
|
||||||
|
E('div', { 'class': 'cbi-section', 'id': 'apps-section' }, [
|
||||||
|
E('h3', {}, _('Applications')),
|
||||||
|
E('div', { 'id': 'apps-table' }, this.renderAppsTable())
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Upload Section
|
||||||
|
E('div', { 'class': 'cbi-section' }, [
|
||||||
|
E('h3', {}, _('Upload App')),
|
||||||
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
|
E('label', { 'class': 'cbi-value-title' }, _('Python File')),
|
||||||
|
E('div', { 'class': 'cbi-value-field' }, [
|
||||||
|
E('input', { 'type': 'file', 'id': 'upload-file', 'accept': '.py' }),
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-action',
|
||||||
|
'style': 'margin-left: 8px',
|
||||||
|
'click': function() { self.uploadApp(); }
|
||||||
|
}, _('Upload'))
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
|
poll.add(function() {
|
||||||
|
return self.refresh().then(function() {
|
||||||
|
self.updateStatus();
|
||||||
|
});
|
||||||
|
}, 5);
|
||||||
|
|
||||||
|
return view;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderControls: function(installed, running) {
|
||||||
|
var self = this;
|
||||||
|
var btns = [];
|
||||||
|
|
||||||
|
if (!installed) {
|
||||||
|
btns.push(E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-positive',
|
||||||
|
'click': function() { self.doInstall(); }
|
||||||
|
}, _('Install')));
|
||||||
|
} else {
|
||||||
|
if (running) {
|
||||||
|
btns.push(E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-negative',
|
||||||
|
'click': function() { self.doStop(); }
|
||||||
|
}, _('Stop')));
|
||||||
|
btns.push(E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-action',
|
||||||
|
'style': 'margin-left: 8px',
|
||||||
|
'click': function() { self.doRestart(); }
|
||||||
|
}, _('Restart')));
|
||||||
|
} else {
|
||||||
|
btns.push(E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-positive',
|
||||||
|
'click': function() { self.doStart(); }
|
||||||
|
}, _('Start')));
|
||||||
|
}
|
||||||
|
btns.push(E('button', {
|
||||||
|
'class': 'cbi-button',
|
||||||
|
'style': 'margin-left: 8px',
|
||||||
|
'click': function() { self.doUninstall(); }
|
||||||
|
}, _('Uninstall')));
|
||||||
|
}
|
||||||
|
|
||||||
|
return btns;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAppsTable: function() {
|
||||||
|
var self = this;
|
||||||
|
var apps = this.apps;
|
||||||
|
|
||||||
|
if (!apps.length) {
|
||||||
|
return E('em', {}, _('No apps found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = apps.map(function(app) {
|
||||||
|
var isActive = app.name === self.activeApp;
|
||||||
|
return E('tr', { 'class': isActive ? 'cbi-rowstyle-2' : '' }, [
|
||||||
|
E('td', {}, [
|
||||||
|
E('strong', {}, app.name),
|
||||||
|
isActive ? E('span', { 'style': 'color:#0a0; margin-left:8px' }, _('(active)')) : ''
|
||||||
|
]),
|
||||||
|
E('td', {}, app.path ? app.path.split('/').pop() : '-'),
|
||||||
|
E('td', {}, [
|
||||||
|
!isActive ? E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-action',
|
||||||
|
'click': function() { self.activateApp(app.name); }
|
||||||
|
}, _('Activate')) : '',
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-remove',
|
||||||
|
'style': 'margin-left: 4px',
|
||||||
|
'click': function() { self.deleteApp(app.name); }
|
||||||
|
}, _('Delete'))
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return E('table', { 'class': 'table cbi-section-table' }, [
|
||||||
|
E('tr', { 'class': 'tr table-titles' }, [
|
||||||
|
E('th', { 'class': 'th' }, _('Name')),
|
||||||
|
E('th', { 'class': 'th' }, _('File')),
|
||||||
|
E('th', { 'class': 'th' }, _('Actions'))
|
||||||
|
])
|
||||||
|
].concat(rows));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus: function() {
|
||||||
|
var s = this.status;
|
||||||
|
var statusEl = document.getElementById('svc-status');
|
||||||
|
var activeEl = document.getElementById('active-app');
|
||||||
|
var appsEl = document.getElementById('apps-table');
|
||||||
|
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = '';
|
||||||
|
if (!s.installed) {
|
||||||
|
statusEl.appendChild(E('em', { 'style': 'color:#999' }, _('Not installed')));
|
||||||
|
} else if (s.running) {
|
||||||
|
statusEl.appendChild(E('span', { 'style': 'color:#0a0' }, _('Running')));
|
||||||
|
} else {
|
||||||
|
statusEl.appendChild(E('span', { 'style': 'color:#a00' }, _('Stopped')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeEl) {
|
||||||
|
activeEl.textContent = this.activeApp || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appsEl) {
|
||||||
|
appsEl.innerHTML = '';
|
||||||
|
appsEl.appendChild(this.renderAppsTable());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
doStart: function() {
|
||||||
|
var self = this;
|
||||||
|
api.start().then(function(r) {
|
||||||
|
if (r && r.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Service started')), 'info');
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
doStop: function() {
|
||||||
|
var self = this;
|
||||||
|
api.stop().then(function(r) {
|
||||||
|
if (r && r.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Service stopped')), 'info');
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
doRestart: function() {
|
||||||
|
var self = this;
|
||||||
|
api.restart().then(function(r) {
|
||||||
|
if (r && r.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Service restarted')), 'info');
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
doInstall: function() {
|
||||||
|
var self = this;
|
||||||
|
ui.showModal(_('Installing'), [
|
||||||
|
E('p', { 'class': 'spinning' }, _('Installing Streamlit platform...'))
|
||||||
|
]);
|
||||||
|
api.install().then(function(r) {
|
||||||
|
if (r && r.started) {
|
||||||
|
self.pollInstall();
|
||||||
|
} else {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.addNotification(null, E('p', {}, r.message || _('Install failed')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
pollInstall: function() {
|
||||||
|
var self = this;
|
||||||
|
var check = function() {
|
||||||
|
api.getInstallProgress().then(function(r) {
|
||||||
|
if (r.status === 'completed') {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.addNotification(null, E('p', {}, _('Installation complete')), 'success');
|
||||||
|
self.refresh();
|
||||||
|
location.reload();
|
||||||
|
} else if (r.status === 'error') {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.addNotification(null, E('p', {}, r.message || _('Install failed')), 'error');
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setTimeout(check, 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
doUninstall: function() {
|
||||||
|
var self = this;
|
||||||
|
ui.showModal(_('Confirm'), [
|
||||||
|
E('p', {}, _('Uninstall Streamlit platform?')),
|
||||||
|
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'))
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
activateApp: function(name) {
|
||||||
|
var self = this;
|
||||||
|
api.setActiveApp(name).then(function(r) {
|
||||||
|
if (r && r.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('App activated: ') + name), 'info');
|
||||||
|
return api.restart();
|
||||||
|
}
|
||||||
|
}).then(function() {
|
||||||
|
self.refresh();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteApp: function(name) {
|
||||||
|
var self = this;
|
||||||
|
ui.showModal(_('Confirm'), [
|
||||||
|
E('p', {}, _('Delete app: ') + name + '?'),
|
||||||
|
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.removeApp(name).then(function(r) {
|
||||||
|
if (r && r.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('App deleted')), 'info');
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('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 first')), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = fileInput.files[0];
|
||||||
|
var name = file.name.replace(/\.py$/, '');
|
||||||
|
var reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = function(e) {
|
||||||
|
var content = btoa(e.target.result);
|
||||||
|
api.uploadApp(name, content).then(function(r) {
|
||||||
|
if (r && r.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('App uploaded: ') + name), 'success');
|
||||||
|
fileInput.value = '';
|
||||||
|
self.refresh();
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, r.message || _('Upload failed')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,648 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
'require view';
|
|
||||||
'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 haproxyReload = rpc.declare({
|
|
||||||
object: 'luci.haproxy',
|
|
||||||
method: 'reload',
|
|
||||||
expect: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
return view.extend({
|
|
||||||
instancesData: [],
|
|
||||||
appsData: [],
|
|
||||||
statusData: {},
|
|
||||||
|
|
||||||
load: function() {
|
|
||||||
return this.refreshData();
|
|
||||||
},
|
|
||||||
|
|
||||||
getLanIp: function() {
|
|
||||||
if (this.statusData && this.statusData.web_url) {
|
|
||||||
var match = this.statusData.web_url.match(/\/\/([^:\/]+)/);
|
|
||||||
if (match) return match[1];
|
|
||||||
}
|
|
||||||
// Fallback: get from network config
|
|
||||||
return '192.168.255.1';
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshData: function() {
|
|
||||||
var self = this;
|
|
||||||
return Promise.all([
|
|
||||||
api.listInstances(),
|
|
||||||
api.listApps(),
|
|
||||||
api.getStatus()
|
|
||||||
]).then(function(results) {
|
|
||||||
self.instancesData = results[0] || [];
|
|
||||||
self.appsData = results[1] || {};
|
|
||||||
self.statusData = results[2] || {};
|
|
||||||
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', { 'style': 'text-align: center;' }, _('Enabled')),
|
|
||||||
E('th', {}, _('Actions'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('tbody', { 'id': 'instances-tbody' }, tableRows)
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderInstanceRow: function(inst) {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
// Enable/disable checkbox
|
|
||||||
var enableCheckbox = E('input', {
|
|
||||||
'type': 'checkbox',
|
|
||||||
'checked': inst.enabled,
|
|
||||||
'style': 'width: 18px; height: 18px; cursor: pointer;',
|
|
||||||
'change': function() {
|
|
||||||
if (this.checked) {
|
|
||||||
self.handleEnable(inst.id);
|
|
||||||
} else {
|
|
||||||
self.handleDisable(inst.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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', { 'style': 'text-align: center;' }, enableCheckbox),
|
|
||||||
E('td', {}, [
|
|
||||||
E('div', { 'class': 'st-btn-group' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn',
|
|
||||||
'style': 'padding: 5px 10px; font-size: 12px; background: #7c3aed; color: #fff;',
|
|
||||||
'click': function() { self.showPublishWizard(inst); }
|
|
||||||
}, ['\uD83C\uDF10 ', _('Publish')]),
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn',
|
|
||||||
'style': 'padding: 5px 10px; font-size: 12px; background: #0ea5e9;',
|
|
||||||
'click': function() { self.showEditDialog(inst); }
|
|
||||||
}, ['\u270F ', _('Edit')]),
|
|
||||||
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 appsList = this.appsData.apps || [];
|
|
||||||
|
|
||||||
// Calculate next available port
|
|
||||||
var usedPorts = this.instancesData.map(function(i) { return i.port; });
|
|
||||||
var nextPort = 8501;
|
|
||||||
while (usedPorts.indexOf(nextPort) !== -1) {
|
|
||||||
nextPort++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build select options array
|
|
||||||
var selectOptions = [E('option', { 'value': '' }, _('-- Select App --'))];
|
|
||||||
if (appsList.length > 0) {
|
|
||||||
appsList.forEach(function(app) {
|
|
||||||
selectOptions.push(E('option', { 'value': app.name + '.py' }, app.name));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
selectOptions.push(E('option', { 'disabled': true }, _('No apps available')));
|
|
||||||
}
|
|
||||||
|
|
||||||
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;'
|
|
||||||
}, selectOptions)
|
|
||||||
]),
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
showPublishWizard: function(inst) {
|
|
||||||
var self = this;
|
|
||||||
var lanIp = this.getLanIp();
|
|
||||||
var port = inst.port;
|
|
||||||
|
|
||||||
ui.showModal(_('Publish Instance to Web'), [
|
|
||||||
E('div', { 'style': 'margin-bottom: 16px;' }, [
|
|
||||||
E('p', { 'style': 'margin-bottom: 12px;' }, [
|
|
||||||
_('Configure HAProxy to expose '),
|
|
||||||
E('strong', {}, inst.id),
|
|
||||||
_(' (port '),
|
|
||||||
E('code', {}, port),
|
|
||||||
_(') 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': inst.id + '.example.com'
|
|
||||||
}),
|
|
||||||
E('small', { 'style': 'color: #64748b;' }, _('Enter the domain that will route to this instance'))
|
|
||||||
]),
|
|
||||||
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 (via cron)')
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
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.publishInstance(inst, domain, lanIp, port, ssl, acme);
|
|
||||||
}
|
|
||||||
}, ['\uD83D\uDE80 ', _('Publish')])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
publishInstance: function(inst, domain, backendIp, backendPort, ssl, acme) {
|
|
||||||
var self = this;
|
|
||||||
var backendName = 'streamlit_' + inst.id;
|
|
||||||
|
|
||||||
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, inst.id, 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();
|
|
||||||
var msg = acme ?
|
|
||||||
_('Instance published! Certificate will be requested via cron.') :
|
|
||||||
_('Instance published successfully!');
|
|
||||||
ui.addNotification(null, E('p', {}, [
|
|
||||||
msg,
|
|
||||||
E('br'),
|
|
||||||
_('URL: '),
|
|
||||||
E('a', {
|
|
||||||
'href': (ssl ? 'https://' : 'http://') + domain,
|
|
||||||
'target': '_blank',
|
|
||||||
'style': 'color: #0ff;'
|
|
||||||
}, (ssl ? 'https://' : 'http://') + domain)
|
|
||||||
]), 'success');
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
ui.hideModal();
|
|
||||||
ui.addNotification(null, E('p', {}, _('Publish failed: ') + (err.message || err)), 'error');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
showEditDialog: function(inst) {
|
|
||||||
var self = this;
|
|
||||||
var appsList = this.appsData.apps || [];
|
|
||||||
|
|
||||||
// Build app options
|
|
||||||
var appOptions = appsList.map(function(app) {
|
|
||||||
var selected = (inst.app === app.name + '.py') ? { 'selected': 'selected' } : {};
|
|
||||||
return E('option', Object.assign({ 'value': app.name + '.py' }, selected), app.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.showModal(_('Edit Instance: ') + inst.id, [
|
|
||||||
E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [
|
|
||||||
E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Display Name')),
|
|
||||||
E('input', {
|
|
||||||
'type': 'text',
|
|
||||||
'id': 'edit-inst-name',
|
|
||||||
'value': inst.name || inst.id,
|
|
||||||
'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;'
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [
|
|
||||||
E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('App File')),
|
|
||||||
E('select', {
|
|
||||||
'id': 'edit-inst-app',
|
|
||||||
'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px; height: 42px;'
|
|
||||||
}, appOptions)
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [
|
|
||||||
E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Port')),
|
|
||||||
E('input', {
|
|
||||||
'type': 'number',
|
|
||||||
'id': 'edit-inst-port',
|
|
||||||
'value': inst.port,
|
|
||||||
'min': '1024',
|
|
||||||
'max': '65535',
|
|
||||||
'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;'
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'right', 'style': 'margin-top: 16px;' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn',
|
|
||||||
'click': ui.hideModal
|
|
||||||
}, _('Cancel')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button-positive',
|
|
||||||
'style': 'margin-left: 8px;',
|
|
||||||
'click': function() {
|
|
||||||
var name = document.getElementById('edit-inst-name').value.trim();
|
|
||||||
var app = document.getElementById('edit-inst-app').value;
|
|
||||||
var port = parseInt(document.getElementById('edit-inst-port').value, 10);
|
|
||||||
|
|
||||||
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')), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.saveInstanceEdit(inst.id, name, app, port);
|
|
||||||
}
|
|
||||||
}, ['\uD83D\uDCBE ', _('Save')])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
saveInstanceEdit: function(id, name, app, port) {
|
|
||||||
var self = this;
|
|
||||||
ui.hideModal();
|
|
||||||
|
|
||||||
// For now, we remove and re-add (since there's no update API)
|
|
||||||
// TODO: Add update_instance to the API
|
|
||||||
api.removeInstance(id).then(function() {
|
|
||||||
return api.addInstance(id, name, app, port);
|
|
||||||
}).then(function(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Instance updated: ') + id), 'success');
|
|
||||||
self.refreshData();
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Failed to update instance')), 'error');
|
|
||||||
}
|
|
||||||
}).catch(function(err) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Error: ') + err.message), 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
'require view';
|
|
||||||
'require ui';
|
|
||||||
'require dom';
|
|
||||||
'require poll';
|
|
||||||
'require streamlit.api as api';
|
|
||||||
|
|
||||||
return view.extend({
|
|
||||||
logsData: null,
|
|
||||||
autoScroll: true,
|
|
||||||
|
|
||||||
load: function() {
|
|
||||||
return this.refreshData();
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshData: function() {
|
|
||||||
var self = this;
|
|
||||||
return api.getLogs(200).then(function(logs) {
|
|
||||||
self.logsData = logs || [];
|
|
||||||
return logs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
render: function() {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
// Inject CSS
|
|
||||||
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.renderLogsCard()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Poll for updates
|
|
||||||
poll.add(function() {
|
|
||||||
return self.refreshData().then(function() {
|
|
||||||
self.updateLogs();
|
|
||||||
});
|
|
||||||
}, 5);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHeader: function() {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
return E('div', { 'class': 'st-header' }, [
|
|
||||||
E('div', { 'class': 'st-header-content' }, [
|
|
||||||
E('div', { 'class': 'st-logo' }, '\uD83D\uDCDC'),
|
|
||||||
E('div', {}, [
|
|
||||||
E('h1', { 'class': 'st-title' }, _('SYSTEM LOGS')),
|
|
||||||
E('p', { 'class': 'st-subtitle' }, _('Real-time container and application logs'))
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-btn-group' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-secondary',
|
|
||||||
'id': 'btn-autoscroll',
|
|
||||||
'click': function() { self.toggleAutoScroll(); }
|
|
||||||
}, [E('span', {}, '\u2193'), ' ' + _('Auto-scroll: ON')]),
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-primary',
|
|
||||||
'click': function() { self.refreshData().then(function() { self.updateLogs(); }); }
|
|
||||||
}, [E('span', {}, '\uD83D\uDD04'), ' ' + _('Refresh')])
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderLogsCard: function() {
|
|
||||||
var logs = this.logsData || [];
|
|
||||||
|
|
||||||
var logsContent;
|
|
||||||
if (logs.length > 0) {
|
|
||||||
logsContent = E('div', {
|
|
||||||
'class': 'st-logs',
|
|
||||||
'id': 'logs-container',
|
|
||||||
'style': 'max-height: 600px; font-size: 11px;'
|
|
||||||
}, logs.map(function(line, idx) {
|
|
||||||
return E('div', {
|
|
||||||
'class': 'st-logs-line',
|
|
||||||
'data-line': idx
|
|
||||||
}, self.formatLogLine(line));
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
logsContent = E('div', { 'class': 'st-empty' }, [
|
|
||||||
E('div', { 'class': 'st-empty-icon' }, '\uD83D\uDCED'),
|
|
||||||
E('div', {}, _('No logs available yet')),
|
|
||||||
E('p', { 'style': 'font-size: 13px; color: #64748b; margin-top: 8px;' },
|
|
||||||
_('Logs will appear here once the Streamlit container is running'))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return E('div', { 'class': 'st-card' }, [
|
|
||||||
E('div', { 'class': 'st-card-header' }, [
|
|
||||||
E('div', { 'class': 'st-card-title' }, [
|
|
||||||
E('span', {}, '\uD83D\uDCBB'),
|
|
||||||
' ' + _('Container Logs')
|
|
||||||
]),
|
|
||||||
E('div', { 'style': 'color: #94a3b8; font-size: 13px;' },
|
|
||||||
logs.length + ' ' + _('lines'))
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-card-body' }, [
|
|
||||||
logsContent
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
formatLogLine: function(line) {
|
|
||||||
if (!line) return '';
|
|
||||||
|
|
||||||
// Add some color coding based on content
|
|
||||||
var color = '#0ff';
|
|
||||||
if (line.includes('ERROR') || line.includes('error') || line.includes('Error')) {
|
|
||||||
color = '#f43f5e';
|
|
||||||
} else if (line.includes('WARNING') || line.includes('warning') || line.includes('Warning')) {
|
|
||||||
color = '#f59e0b';
|
|
||||||
} else if (line.includes('INFO') || line.includes('info')) {
|
|
||||||
color = '#10b981';
|
|
||||||
} else if (line.includes('DEBUG') || line.includes('debug')) {
|
|
||||||
color = '#64748b';
|
|
||||||
}
|
|
||||||
|
|
||||||
return E('span', { 'style': 'color: ' + color }, line);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateLogs: function() {
|
|
||||||
var self = this;
|
|
||||||
var container = document.getElementById('logs-container');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
var logs = this.logsData || [];
|
|
||||||
|
|
||||||
container.innerHTML = '';
|
|
||||||
logs.forEach(function(line, idx) {
|
|
||||||
container.appendChild(E('div', {
|
|
||||||
'class': 'st-logs-line',
|
|
||||||
'data-line': idx
|
|
||||||
}, self.formatLogLine(line)));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
if (this.autoScroll) {
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleAutoScroll: function() {
|
|
||||||
this.autoScroll = !this.autoScroll;
|
|
||||||
var btn = document.getElementById('btn-autoscroll');
|
|
||||||
if (btn) {
|
|
||||||
btn.innerHTML = '';
|
|
||||||
btn.appendChild(E('span', {}, '\u2193'));
|
|
||||||
btn.appendChild(document.createTextNode(' ' + _('Auto-scroll: ') + (this.autoScroll ? 'ON' : 'OFF')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,501 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
'require view';
|
|
||||||
'require ui';
|
|
||||||
'require dom';
|
|
||||||
'require poll';
|
|
||||||
'require streamlit.api as api';
|
|
||||||
|
|
||||||
return view.extend({
|
|
||||||
statusData: null,
|
|
||||||
appsData: null,
|
|
||||||
logsData: null,
|
|
||||||
installProgress: null,
|
|
||||||
|
|
||||||
load: function() {
|
|
||||||
return this.refreshData();
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshData: function() {
|
|
||||||
var self = this;
|
|
||||||
return api.getDashboardData().then(function(data) {
|
|
||||||
self.statusData = data.status || {};
|
|
||||||
self.appsData = data.apps || {};
|
|
||||||
self.logsData = data.logs || [];
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
render: function() {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
// Inject CSS
|
|
||||||
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.renderStatsGrid(),
|
|
||||||
this.renderMainGrid()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Poll for updates
|
|
||||||
poll.add(function() {
|
|
||||||
return self.refreshData().then(function() {
|
|
||||||
self.updateDynamicContent();
|
|
||||||
});
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHeader: function() {
|
|
||||||
var status = this.statusData;
|
|
||||||
var statusClass = !status.installed ? 'not-installed' : (status.running ? 'running' : 'stopped');
|
|
||||||
var statusText = !status.installed ? _('Not Installed') : (status.running ? _('Running') : _('Stopped'));
|
|
||||||
|
|
||||||
return E('div', { 'class': 'st-header' }, [
|
|
||||||
E('div', { 'class': 'st-header-content' }, [
|
|
||||||
E('div', { 'class': 'st-logo' }, '\u26A1'),
|
|
||||||
E('div', {}, [
|
|
||||||
E('h1', { 'class': 'st-title' }, _('STREAMLIT PLATFORM')),
|
|
||||||
E('p', { 'class': 'st-subtitle' }, _('Neural Data App Hosting for SecuBox'))
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-status-badge ' + statusClass, 'id': 'st-status-badge' }, [
|
|
||||||
E('span', {}, statusClass === 'running' ? '\u25CF' : '\u25CB'),
|
|
||||||
' ' + statusText
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderStatsGrid: function() {
|
|
||||||
var status = this.statusData;
|
|
||||||
var apps = this.appsData;
|
|
||||||
var appCount = (apps.apps || []).length;
|
|
||||||
|
|
||||||
var stats = [
|
|
||||||
{
|
|
||||||
icon: '\uD83D\uDD0C',
|
|
||||||
label: _('Status'),
|
|
||||||
value: status.running ? _('Online') : _('Offline'),
|
|
||||||
id: 'stat-status',
|
|
||||||
cardClass: status.running ? 'success' : 'error'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '\uD83C\uDF10',
|
|
||||||
label: _('Port'),
|
|
||||||
value: status.http_port || '8501',
|
|
||||||
id: 'stat-port'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '\uD83D\uDCBB',
|
|
||||||
label: _('Apps'),
|
|
||||||
value: appCount,
|
|
||||||
id: 'stat-apps'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '\u26A1',
|
|
||||||
label: _('Active App'),
|
|
||||||
value: status.active_app || 'hello',
|
|
||||||
id: 'stat-active'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return E('div', { 'class': 'st-stats-grid' },
|
|
||||||
stats.map(function(stat) {
|
|
||||||
return E('div', { 'class': 'st-stat-card ' + (stat.cardClass || '') }, [
|
|
||||||
E('div', { 'class': 'st-stat-icon' }, stat.icon),
|
|
||||||
E('div', { 'class': 'st-stat-content' }, [
|
|
||||||
E('div', { 'class': 'st-stat-value', 'id': stat.id }, String(stat.value)),
|
|
||||||
E('div', { 'class': 'st-stat-label' }, stat.label)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderMainGrid: function() {
|
|
||||||
return E('div', { 'class': 'st-main-grid' }, [
|
|
||||||
this.renderControlCard(),
|
|
||||||
this.renderInfoCard(),
|
|
||||||
this.renderInstancesCard()
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderControlCard: function() {
|
|
||||||
var self = this;
|
|
||||||
var status = this.statusData;
|
|
||||||
|
|
||||||
var buttons = [];
|
|
||||||
|
|
||||||
if (!status.installed) {
|
|
||||||
buttons.push(
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-primary',
|
|
||||||
'id': 'btn-install',
|
|
||||||
'click': function() { self.handleInstall(); }
|
|
||||||
}, [E('span', {}, '\uD83D\uDCE5'), ' ' + _('Install')])
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (status.running) {
|
|
||||||
buttons.push(
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-danger',
|
|
||||||
'id': 'btn-stop',
|
|
||||||
'click': function() { self.handleStop(); }
|
|
||||||
}, [E('span', {}, '\u23F9'), ' ' + _('Stop')])
|
|
||||||
);
|
|
||||||
buttons.push(
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-warning',
|
|
||||||
'id': 'btn-restart',
|
|
||||||
'click': function() { self.handleRestart(); }
|
|
||||||
}, [E('span', {}, '\uD83D\uDD04'), ' ' + _('Restart')])
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
buttons.push(
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-success',
|
|
||||||
'id': 'btn-start',
|
|
||||||
'click': function() { self.handleStart(); }
|
|
||||||
}, [E('span', {}, '\u25B6'), ' ' + _('Start')])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons.push(
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-primary',
|
|
||||||
'id': 'btn-update',
|
|
||||||
'click': function() { self.handleUpdate(); }
|
|
||||||
}, [E('span', {}, '\u2B06'), ' ' + _('Update')])
|
|
||||||
);
|
|
||||||
|
|
||||||
buttons.push(
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-danger',
|
|
||||||
'id': 'btn-uninstall',
|
|
||||||
'click': function() { self.handleUninstall(); }
|
|
||||||
}, [E('span', {}, '\uD83D\uDDD1'), ' ' + _('Uninstall')])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return E('div', { 'class': 'st-card' }, [
|
|
||||||
E('div', { 'class': 'st-card-header' }, [
|
|
||||||
E('div', { 'class': 'st-card-title' }, [
|
|
||||||
E('span', {}, '\uD83C\uDFAE'),
|
|
||||||
' ' + _('Controls')
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-card-body' }, [
|
|
||||||
E('div', { 'class': 'st-btn-group', 'id': 'st-controls' }, buttons),
|
|
||||||
E('div', { 'class': 'st-progress', 'id': 'st-progress-container', 'style': 'display:none' }, [
|
|
||||||
E('div', { 'class': 'st-progress-bar', 'id': 'st-progress-bar', 'style': 'width:0%' })
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-progress-text', 'id': 'st-progress-text', 'style': 'display:none' })
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderInfoCard: function() {
|
|
||||||
var status = this.statusData;
|
|
||||||
|
|
||||||
var infoItems = [
|
|
||||||
{ label: _('Container'), value: status.container_name || 'streamlit' },
|
|
||||||
{ label: _('Data Path'), value: status.data_path || '/srv/streamlit' },
|
|
||||||
{ label: _('Memory Limit'), value: status.memory_limit || '512M' },
|
|
||||||
{ label: _('Web Interface'), value: status.web_url, isLink: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
return E('div', { 'class': 'st-card' }, [
|
|
||||||
E('div', { 'class': 'st-card-header' }, [
|
|
||||||
E('div', { 'class': 'st-card-title' }, [
|
|
||||||
E('span', {}, '\u2139\uFE0F'),
|
|
||||||
' ' + _('Information')
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-card-body' }, [
|
|
||||||
E('ul', { 'class': 'st-info-list', 'id': 'st-info-list' },
|
|
||||||
infoItems.map(function(item) {
|
|
||||||
var valueEl;
|
|
||||||
if (item.isLink && item.value) {
|
|
||||||
valueEl = E('a', { 'href': item.value, 'target': '_blank' }, item.value);
|
|
||||||
} else {
|
|
||||||
valueEl = item.value || '-';
|
|
||||||
}
|
|
||||||
return E('li', {}, [
|
|
||||||
E('span', { 'class': 'st-info-label' }, item.label),
|
|
||||||
E('span', { 'class': 'st-info-value' }, valueEl)
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderInstancesCard: function() {
|
|
||||||
var apps = this.appsData || {};
|
|
||||||
var instances = apps.apps || [];
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
return E('div', { 'class': 'st-card st-card-full' }, [
|
|
||||||
E('div', { 'class': 'st-card-header' }, [
|
|
||||||
E('div', { 'class': 'st-card-title' }, [
|
|
||||||
E('span', {}, '\uD83D\uDCCA'),
|
|
||||||
' ' + _('Instances')
|
|
||||||
]),
|
|
||||||
E('a', {
|
|
||||||
'href': L.url('admin', 'services', 'streamlit', 'apps'),
|
|
||||||
'class': 'st-link'
|
|
||||||
}, _('Manage Apps') + ' \u2192')
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-card-body st-no-padding' }, [
|
|
||||||
instances.length > 0 ?
|
|
||||||
E('table', { 'class': 'st-instances-table', 'id': 'st-instances' }, [
|
|
||||||
E('thead', {}, [
|
|
||||||
E('tr', {}, [
|
|
||||||
E('th', {}, _('App')),
|
|
||||||
E('th', {}, _('Port')),
|
|
||||||
E('th', {}, _('Status')),
|
|
||||||
E('th', {}, _('Published')),
|
|
||||||
E('th', {}, _('Domain'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('tbody', {},
|
|
||||||
instances.map(function(app) {
|
|
||||||
var isActive = app.active || (self.statusData && self.statusData.active_app === app.name);
|
|
||||||
var isRunning = isActive && self.statusData && self.statusData.running;
|
|
||||||
var statusIcon = isRunning ? '\uD83D\uDFE2' : '\uD83D\uDD34';
|
|
||||||
var statusText = isRunning ? _('Running') : _('Stopped');
|
|
||||||
var publishedIcon = app.published ? '\u2705' : '\u26AA';
|
|
||||||
var domain = app.domain || (app.published ? app.name + '.example.com' : '-');
|
|
||||||
|
|
||||||
return E('tr', { 'class': isActive ? 'st-row-active' : '' }, [
|
|
||||||
E('td', {}, [
|
|
||||||
E('strong', {}, app.name || app.id),
|
|
||||||
app.description ? E('div', { 'class': 'st-app-desc' }, app.description) : ''
|
|
||||||
]),
|
|
||||||
E('td', { 'class': 'st-mono' }, String(app.port || 8501)),
|
|
||||||
E('td', {}, [
|
|
||||||
E('span', { 'class': 'st-status-dot ' + (isRunning ? 'st-running' : 'st-stopped') }, statusIcon),
|
|
||||||
' ' + statusText
|
|
||||||
]),
|
|
||||||
E('td', {}, publishedIcon),
|
|
||||||
E('td', {}, domain !== '-' ?
|
|
||||||
E('a', { 'href': 'https://' + domain, 'target': '_blank' }, domain) :
|
|
||||||
'-'
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
]) :
|
|
||||||
E('div', { 'class': 'st-empty' }, [
|
|
||||||
E('div', { 'class': 'st-empty-icon' }, '\uD83D\uDCE6'),
|
|
||||||
E('div', {}, _('No apps deployed')),
|
|
||||||
E('a', {
|
|
||||||
'href': L.url('admin', 'services', 'streamlit', 'apps'),
|
|
||||||
'class': 'st-btn st-btn-primary st-btn-sm'
|
|
||||||
}, _('Deploy First App'))
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateDynamicContent: function() {
|
|
||||||
var status = this.statusData;
|
|
||||||
|
|
||||||
// Update status badge
|
|
||||||
var badge = document.getElementById('st-status-badge');
|
|
||||||
if (badge) {
|
|
||||||
var statusClass = !status.installed ? 'not-installed' : (status.running ? 'running' : 'stopped');
|
|
||||||
var statusText = !status.installed ? _('Not Installed') : (status.running ? _('Running') : _('Stopped'));
|
|
||||||
badge.className = 'st-status-badge ' + statusClass;
|
|
||||||
badge.innerHTML = '';
|
|
||||||
badge.appendChild(E('span', {}, statusClass === 'running' ? '\u25CF' : '\u25CB'));
|
|
||||||
badge.appendChild(document.createTextNode(' ' + statusText));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
var statStatus = document.getElementById('stat-status');
|
|
||||||
if (statStatus) {
|
|
||||||
statStatus.textContent = status.running ? _('Online') : _('Offline');
|
|
||||||
}
|
|
||||||
|
|
||||||
var statActive = document.getElementById('stat-active');
|
|
||||||
if (statActive) {
|
|
||||||
statActive.textContent = status.active_app || 'hello';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update instances table status indicators
|
|
||||||
var instancesTable = document.getElementById('st-instances');
|
|
||||||
if (instancesTable) {
|
|
||||||
var apps = this.appsData && this.appsData.apps || [];
|
|
||||||
var rows = instancesTable.querySelectorAll('tbody tr');
|
|
||||||
rows.forEach(function(row, idx) {
|
|
||||||
if (apps[idx]) {
|
|
||||||
var app = apps[idx];
|
|
||||||
var isActive = app.active || (self.statusData && self.statusData.active_app === app.name);
|
|
||||||
var isRunning = isActive && self.statusData && self.statusData.running;
|
|
||||||
row.className = isActive ? 'st-row-active' : '';
|
|
||||||
var statusCell = row.querySelector('td:nth-child(3)');
|
|
||||||
if (statusCell) {
|
|
||||||
statusCell.innerHTML = '';
|
|
||||||
var statusIcon = isRunning ? '\uD83D\uDFE2' : '\uD83D\uDD34';
|
|
||||||
var statusText = isRunning ? _('Running') : _('Stopped');
|
|
||||||
statusCell.appendChild(E('span', { 'class': 'st-status-dot ' + (isRunning ? 'st-running' : 'st-stopped') }, statusIcon));
|
|
||||||
statusCell.appendChild(document.createTextNode(' ' + statusText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleInstall: function() {
|
|
||||||
var self = this;
|
|
||||||
var btn = document.getElementById('btn-install');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
|
|
||||||
ui.showModal(_('Installing Streamlit Platform'), [
|
|
||||||
E('p', {}, _('This will download Alpine Linux rootfs and install Python 3.12 with Streamlit. This may take several minutes.')),
|
|
||||||
E('div', { 'class': 'st-progress' }, [
|
|
||||||
E('div', { 'class': 'st-progress-bar', 'id': 'modal-progress', 'style': 'width:0%' })
|
|
||||||
]),
|
|
||||||
E('p', { 'id': 'modal-status' }, _('Starting installation...'))
|
|
||||||
]);
|
|
||||||
|
|
||||||
api.install().then(function(result) {
|
|
||||||
if (result && result.started) {
|
|
||||||
self.pollInstallProgress();
|
|
||||||
} else {
|
|
||||||
ui.hideModal();
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Installation failed')), 'error');
|
|
||||||
}
|
|
||||||
}).catch(function(err) {
|
|
||||||
ui.hideModal();
|
|
||||||
ui.addNotification(null, E('p', {}, _('Installation failed: ') + err.message), 'error');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
pollInstallProgress: function() {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
var checkProgress = function() {
|
|
||||||
api.getInstallProgress().then(function(result) {
|
|
||||||
var progressBar = document.getElementById('modal-progress');
|
|
||||||
var statusText = document.getElementById('modal-status');
|
|
||||||
|
|
||||||
if (progressBar) {
|
|
||||||
progressBar.style.width = (result.progress || 0) + '%';
|
|
||||||
}
|
|
||||||
if (statusText) {
|
|
||||||
statusText.textContent = result.message || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === 'completed') {
|
|
||||||
ui.hideModal();
|
|
||||||
ui.addNotification(null, E('p', {}, _('Streamlit Platform installed successfully!')), 'success');
|
|
||||||
self.refreshData();
|
|
||||||
location.reload();
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
ui.hideModal();
|
|
||||||
ui.addNotification(null, E('p', {}, _('Installation failed: ') + result.message), 'error');
|
|
||||||
} else if (result.status === 'running') {
|
|
||||||
setTimeout(checkProgress, 3000);
|
|
||||||
} else {
|
|
||||||
setTimeout(checkProgress, 3000);
|
|
||||||
}
|
|
||||||
}).catch(function() {
|
|
||||||
setTimeout(checkProgress, 5000);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
setTimeout(checkProgress, 2000);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleStart: function() {
|
|
||||||
var self = this;
|
|
||||||
api.start().then(function(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Streamlit Platform started')), 'success');
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Failed to start')), 'error');
|
|
||||||
}
|
|
||||||
self.refreshData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
handleStop: function() {
|
|
||||||
var self = this;
|
|
||||||
api.stop().then(function(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Streamlit Platform stopped')), 'info');
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Failed to stop')), 'error');
|
|
||||||
}
|
|
||||||
self.refreshData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
handleRestart: function() {
|
|
||||||
var self = this;
|
|
||||||
api.restart().then(function(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Streamlit Platform restarted')), 'success');
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Failed to restart')), 'error');
|
|
||||||
}
|
|
||||||
self.refreshData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
handleUpdate: function() {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
ui.showModal(_('Updating Streamlit'), [
|
|
||||||
E('p', {}, _('Updating Streamlit to the latest version...')),
|
|
||||||
E('div', { 'class': 'spinning' })
|
|
||||||
]);
|
|
||||||
|
|
||||||
api.update().then(function(result) {
|
|
||||||
ui.hideModal();
|
|
||||||
if (result && result.started) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Update started. The server will restart automatically.')), 'info');
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Update failed')), 'error');
|
|
||||||
}
|
|
||||||
self.refreshData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
handleUninstall: function() {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
ui.showModal(_('Confirm Uninstall'), [
|
|
||||||
E('p', {}, _('Are you sure you want to uninstall Streamlit Platform? Your apps will be preserved.')),
|
|
||||||
E('div', { 'class': 'right' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn',
|
|
||||||
'click': ui.hideModal
|
|
||||||
}, _('Cancel')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button-negative',
|
|
||||||
'click': function() {
|
|
||||||
ui.hideModal();
|
|
||||||
api.uninstall().then(function(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Streamlit Platform uninstalled')), 'info');
|
|
||||||
} else {
|
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Uninstall failed')), 'error');
|
|
||||||
}
|
|
||||||
self.refreshData();
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, _('Uninstall'))
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,257 +1,148 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
'require view';
|
'require view';
|
||||||
'require ui';
|
'require ui';
|
||||||
'require dom';
|
|
||||||
'require streamlit.api as api';
|
'require streamlit.api as api';
|
||||||
|
|
||||||
return view.extend({
|
return view.extend({
|
||||||
configData: null,
|
config: {},
|
||||||
|
|
||||||
load: function() {
|
load: function() {
|
||||||
return api.getConfig().then(function(config) {
|
return api.getConfig().then(function(c) {
|
||||||
return config;
|
return c || {};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
render: function(configData) {
|
render: function(config) {
|
||||||
var self = this;
|
var self = this;
|
||||||
this.configData = configData || {};
|
this.config = config;
|
||||||
|
var main = config.main || {};
|
||||||
|
var server = config.server || {};
|
||||||
|
|
||||||
// Inject CSS
|
return E('div', { 'class': 'cbi-map' }, [
|
||||||
var cssLink = E('link', {
|
E('h2', {}, _('Streamlit Settings')),
|
||||||
'rel': 'stylesheet',
|
|
||||||
'type': 'text/css',
|
|
||||||
'href': L.resource('streamlit/dashboard.css')
|
|
||||||
});
|
|
||||||
|
|
||||||
var container = E('div', { 'class': 'streamlit-dashboard' }, [
|
// Main Settings
|
||||||
cssLink,
|
E('div', { 'class': 'cbi-section' }, [
|
||||||
this.renderHeader(),
|
E('h3', {}, _('Service')),
|
||||||
E('div', { 'class': 'st-main-grid' }, [
|
|
||||||
this.renderMainSettings(),
|
|
||||||
this.renderServerSettings()
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
|
|
||||||
return container;
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
},
|
E('label', { 'class': 'cbi-value-title' }, _('Enabled')),
|
||||||
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
renderHeader: function() {
|
E('select', { 'id': 'cfg-enabled', 'class': 'cbi-input-select' }, [
|
||||||
var self = this;
|
E('option', { 'value': '1', 'selected': main.enabled == '1' || main.enabled === true }, _('Yes')),
|
||||||
|
E('option', { 'value': '0', 'selected': main.enabled == '0' || main.enabled === false }, _('No'))
|
||||||
return E('div', { 'class': 'st-header' }, [
|
])
|
||||||
E('div', { 'class': 'st-header-content' }, [
|
)
|
||||||
E('div', { 'class': 'st-logo' }, '\u2699\uFE0F'),
|
|
||||||
E('div', {}, [
|
|
||||||
E('h1', { 'class': 'st-title' }, _('SETTINGS')),
|
|
||||||
E('p', { 'class': 'st-subtitle' }, _('Configure Streamlit Platform options'))
|
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'st-btn-group' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'st-btn st-btn-success',
|
|
||||||
'click': function() { self.saveSettings(); }
|
|
||||||
}, [E('span', {}, '\uD83D\uDCBE'), ' ' + _('Save Settings')])
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderMainSettings: function() {
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
var config = this.configData.main || {};
|
E('label', { 'class': 'cbi-value-title' }, _('HTTP Port')),
|
||||||
var isEnabled = config.enabled === true || config.enabled === 1 || config.enabled === '1';
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
|
E('input', { 'type': 'number', 'id': 'cfg-port', 'class': 'cbi-input-text',
|
||||||
|
'value': main.http_port || '8501', 'min': '1024', 'max': '65535' })
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
|
||||||
// Normalize memory limit for comparison
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
var memLimit = config.memory_limit || '1024M';
|
E('label', { 'class': 'cbi-value-title' }, _('Listen Address')),
|
||||||
if (memLimit === '1G') memLimit = '1024M';
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
if (memLimit === '2G') memLimit = '2048M';
|
E('input', { 'type': 'text', 'id': 'cfg-host', 'class': 'cbi-input-text',
|
||||||
if (memLimit === '4G') memLimit = '4096M';
|
'value': main.http_host || '0.0.0.0' })
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
|
||||||
return E('div', { 'class': 'st-card' }, [
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
E('div', { 'class': 'st-card-header' }, [
|
E('label', { 'class': 'cbi-value-title' }, _('Data Path')),
|
||||||
E('div', { 'class': 'st-card-title' }, [
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
E('span', {}, '\uD83D\uDD27'),
|
E('input', { 'type': 'text', 'id': 'cfg-path', 'class': 'cbi-input-text',
|
||||||
' ' + _('Main Settings')
|
'value': main.data_path || '/srv/streamlit' })
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
|
||||||
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
|
E('label', { 'class': 'cbi-value-title' }, _('Memory Limit')),
|
||||||
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
|
E('select', { 'id': 'cfg-memory', 'class': 'cbi-input-select' }, [
|
||||||
|
E('option', { 'value': '512M', 'selected': main.memory_limit === '512M' }, '512 MB'),
|
||||||
|
E('option', { 'value': '1G', 'selected': main.memory_limit === '1G' }, '1 GB'),
|
||||||
|
E('option', { 'value': '2G', 'selected': main.memory_limit === '2G' || !main.memory_limit }, '2 GB'),
|
||||||
|
E('option', { 'value': '4G', 'selected': main.memory_limit === '4G' }, '4 GB')
|
||||||
|
])
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
|
||||||
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
|
E('label', { 'class': 'cbi-value-title' }, _('Active App')),
|
||||||
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
|
E('input', { 'type': 'text', 'id': 'cfg-app', 'class': 'cbi-input-text',
|
||||||
|
'value': main.active_app || 'hello' })
|
||||||
|
)
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'st-card-body' }, [
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('Enabled')),
|
|
||||||
E('select', {
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-enabled',
|
|
||||||
'style': 'height: 42px;'
|
|
||||||
}, [
|
|
||||||
E('option', Object.assign({ 'value': '1' }, isEnabled ? { 'selected': 'selected' } : {}), _('Enabled')),
|
|
||||||
E('option', Object.assign({ 'value': '0' }, !isEnabled ? { 'selected': 'selected' } : {}), _('Disabled'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('HTTP Port')),
|
|
||||||
E('input', {
|
|
||||||
'type': 'number',
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-http_port',
|
|
||||||
'value': config.http_port || 8501,
|
|
||||||
'min': 1,
|
|
||||||
'max': 65535
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('HTTP Host')),
|
|
||||||
E('input', {
|
|
||||||
'type': 'text',
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-http_host',
|
|
||||||
'value': config.http_host || '0.0.0.0',
|
|
||||||
'placeholder': '0.0.0.0'
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('Data Path')),
|
|
||||||
E('input', {
|
|
||||||
'type': 'text',
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-data_path',
|
|
||||||
'value': config.data_path || '/srv/streamlit',
|
|
||||||
'placeholder': '/srv/streamlit'
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('Memory Limit')),
|
|
||||||
E('select', {
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-memory_limit',
|
|
||||||
'style': 'height: 42px;'
|
|
||||||
}, [
|
|
||||||
E('option', Object.assign({ 'value': '256M' }, memLimit === '256M' ? { 'selected': 'selected' } : {}), '256 MB'),
|
|
||||||
E('option', Object.assign({ 'value': '512M' }, memLimit === '512M' ? { 'selected': 'selected' } : {}), '512 MB'),
|
|
||||||
E('option', Object.assign({ 'value': '1024M' }, memLimit === '1024M' ? { 'selected': 'selected' } : {}), '1 GB'),
|
|
||||||
E('option', Object.assign({ 'value': '2048M' }, memLimit === '2048M' ? { 'selected': 'selected' } : {}), '2 GB'),
|
|
||||||
E('option', Object.assign({ 'value': '4096M' }, memLimit === '4096M' ? { 'selected': 'selected' } : {}), '4 GB')
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('Active App')),
|
|
||||||
E('input', {
|
|
||||||
'type': 'text',
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-active_app',
|
|
||||||
'value': config.active_app || 'hello',
|
|
||||||
'placeholder': 'hello'
|
|
||||||
})
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderServerSettings: function() {
|
// Server Settings
|
||||||
var config = this.configData.server || {};
|
E('div', { 'class': 'cbi-section' }, [
|
||||||
|
E('h3', {}, _('Server Options')),
|
||||||
|
|
||||||
// Normalize boolean values (can be true/false boolean or "true"/"false" string)
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
var isHeadless = config.headless === true || config.headless === 'true';
|
E('label', { 'class': 'cbi-value-title' }, _('Headless')),
|
||||||
var gatherStats = config.browser_gather_usage_stats === true || config.browser_gather_usage_stats === 'true';
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
var themeBase = config.theme_base || 'dark';
|
E('select', { 'id': 'cfg-headless', 'class': 'cbi-input-select' }, [
|
||||||
|
E('option', { 'value': 'true', 'selected': server.headless !== 'false' }, _('Yes')),
|
||||||
|
E('option', { 'value': 'false', 'selected': server.headless === 'false' }, _('No'))
|
||||||
|
])
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
|
||||||
return E('div', { 'class': 'st-card' }, [
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
E('div', { 'class': 'st-card-header' }, [
|
E('label', { 'class': 'cbi-value-title' }, _('Theme')),
|
||||||
E('div', { 'class': 'st-card-title' }, [
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
E('span', {}, '\uD83C\uDFA8'),
|
E('select', { 'id': 'cfg-theme', 'class': 'cbi-input-select' }, [
|
||||||
' ' + _('Server & Theme')
|
E('option', { 'value': 'dark', 'selected': server.theme_base !== 'light' }, _('Dark')),
|
||||||
|
E('option', { 'value': 'light', 'selected': server.theme_base === 'light' }, _('Light'))
|
||||||
|
])
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
|
||||||
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
|
E('label', { 'class': 'cbi-value-title' }, _('Primary Color')),
|
||||||
|
E('div', { 'class': 'cbi-value-field' },
|
||||||
|
E('input', { 'type': 'text', 'id': 'cfg-color', 'class': 'cbi-input-text',
|
||||||
|
'value': server.theme_primary_color || '#0ff', 'placeholder': '#0ff' })
|
||||||
|
)
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'st-card-body' }, [
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
// Save button
|
||||||
E('label', { 'class': 'st-form-label' }, _('Headless Mode')),
|
E('div', { 'class': 'cbi-page-actions' }, [
|
||||||
E('select', {
|
E('button', {
|
||||||
'class': 'st-form-input',
|
'class': 'cbi-button cbi-button-positive',
|
||||||
'id': 'cfg-headless',
|
'click': function() { self.save(); }
|
||||||
'style': 'height: 42px;'
|
}, _('Save & Apply'))
|
||||||
}, [
|
|
||||||
E('option', Object.assign({ 'value': 'true' }, isHeadless ? { 'selected': 'selected' } : {}), _('Enabled (recommended)')),
|
|
||||||
E('option', Object.assign({ 'value': 'false' }, !isHeadless ? { 'selected': 'selected' } : {}), _('Disabled'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('Usage Statistics')),
|
|
||||||
E('select', {
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-gather_stats',
|
|
||||||
'style': 'height: 42px;'
|
|
||||||
}, [
|
|
||||||
E('option', Object.assign({ 'value': 'false' }, !gatherStats ? { 'selected': 'selected' } : {}), _('Disabled (recommended)')),
|
|
||||||
E('option', Object.assign({ 'value': 'true' }, gatherStats ? { 'selected': 'selected' } : {}), _('Enabled'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('Theme Base')),
|
|
||||||
E('select', {
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-theme_base',
|
|
||||||
'style': 'height: 42px;'
|
|
||||||
}, [
|
|
||||||
E('option', Object.assign({ 'value': 'dark' }, themeBase === 'dark' ? { 'selected': 'selected' } : {}), _('Dark')),
|
|
||||||
E('option', Object.assign({ 'value': 'light' }, themeBase === 'light' ? { 'selected': 'selected' } : {}), _('Light'))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'st-form-group' }, [
|
|
||||||
E('label', { 'class': 'st-form-label' }, _('Primary Color')),
|
|
||||||
E('div', { 'style': 'display: flex; gap: 10px; align-items: center;' }, [
|
|
||||||
E('input', {
|
|
||||||
'type': 'color',
|
|
||||||
'id': 'cfg-theme_primary_picker',
|
|
||||||
'value': config.theme_primary_color || '#00ffff',
|
|
||||||
'style': 'width: 50px; height: 40px; border: none; background: none; cursor: pointer;',
|
|
||||||
'change': function() {
|
|
||||||
document.getElementById('cfg-theme_primary').value = this.value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
E('input', {
|
|
||||||
'type': 'text',
|
|
||||||
'class': 'st-form-input',
|
|
||||||
'id': 'cfg-theme_primary',
|
|
||||||
'value': config.theme_primary_color || '#0ff',
|
|
||||||
'placeholder': '#0ff',
|
|
||||||
'style': 'flex: 1;',
|
|
||||||
'change': function() {
|
|
||||||
document.getElementById('cfg-theme_primary_picker').value = this.value;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
E('div', { 'style': 'margin-top: 20px; padding: 16px; background: rgba(0, 255, 255, 0.05); border-radius: 8px; border: 1px solid rgba(0, 255, 255, 0.2);' }, [
|
|
||||||
E('p', { 'style': 'color: #0ff; font-size: 13px; margin: 0;' }, [
|
|
||||||
E('strong', {}, _('Note: ')),
|
|
||||||
_('Changes will take effect after restarting the Streamlit service.')
|
|
||||||
])
|
|
||||||
])
|
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
saveSettings: function() {
|
save: function() {
|
||||||
var self = this;
|
var cfg = {
|
||||||
|
|
||||||
var config = {
|
|
||||||
enabled: document.getElementById('cfg-enabled').value,
|
enabled: document.getElementById('cfg-enabled').value,
|
||||||
http_port: parseInt(document.getElementById('cfg-http_port').value, 10),
|
http_port: document.getElementById('cfg-port').value,
|
||||||
http_host: document.getElementById('cfg-http_host').value,
|
http_host: document.getElementById('cfg-host').value,
|
||||||
data_path: document.getElementById('cfg-data_path').value,
|
data_path: document.getElementById('cfg-path').value,
|
||||||
memory_limit: document.getElementById('cfg-memory_limit').value,
|
memory_limit: document.getElementById('cfg-memory').value,
|
||||||
active_app: document.getElementById('cfg-active_app').value,
|
active_app: document.getElementById('cfg-app').value,
|
||||||
headless: document.getElementById('cfg-headless').value,
|
headless: document.getElementById('cfg-headless').value,
|
||||||
browser_gather_usage_stats: document.getElementById('cfg-gather_stats').value,
|
browser_gather_usage_stats: 'false',
|
||||||
theme_base: document.getElementById('cfg-theme_base').value,
|
theme_base: document.getElementById('cfg-theme').value,
|
||||||
theme_primary_color: document.getElementById('cfg-theme_primary').value
|
theme_primary_color: document.getElementById('cfg-color').value
|
||||||
};
|
};
|
||||||
|
|
||||||
api.saveConfig(config).then(function(result) {
|
api.saveConfig(cfg).then(function(r) {
|
||||||
if (result && result.success) {
|
if (r && r.success) {
|
||||||
ui.addNotification(null, E('p', {}, _('Settings saved successfully')), 'success');
|
ui.addNotification(null, E('p', {}, _('Settings saved')), 'info');
|
||||||
} else {
|
} else {
|
||||||
ui.addNotification(null, E('p', {}, result.message || _('Failed to save settings')), 'error');
|
ui.addNotification(null, E('p', {}, r.message || _('Save failed')), 'error');
|
||||||
}
|
}
|
||||||
}).catch(function(err) {
|
|
||||||
ui.addNotification(null, E('p', {}, _('Failed to save: ') + err.message), 'error');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,41 +10,17 @@
|
|||||||
"uci": {"streamlit": true}
|
"uci": {"streamlit": true}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin/services/streamlit/overview": {
|
"admin/services/streamlit/dashboard": {
|
||||||
"title": "Overview",
|
"title": "Dashboard",
|
||||||
"order": 10,
|
"order": 10,
|
||||||
"action": {
|
"action": {
|
||||||
"type": "view",
|
"type": "view",
|
||||||
"path": "streamlit/overview"
|
"path": "streamlit/dashboard"
|
||||||
}
|
|
||||||
},
|
|
||||||
"admin/services/streamlit/apps": {
|
|
||||||
"title": "Apps",
|
|
||||||
"order": 20,
|
|
||||||
"action": {
|
|
||||||
"type": "view",
|
|
||||||
"path": "streamlit/apps"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"admin/services/streamlit/instances": {
|
|
||||||
"title": "Instances",
|
|
||||||
"order": 25,
|
|
||||||
"action": {
|
|
||||||
"type": "view",
|
|
||||||
"path": "streamlit/instances"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"admin/services/streamlit/logs": {
|
|
||||||
"title": "Logs",
|
|
||||||
"order": 30,
|
|
||||||
"action": {
|
|
||||||
"type": "view",
|
|
||||||
"path": "streamlit/logs"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin/services/streamlit/settings": {
|
"admin/services/streamlit/settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"order": 40,
|
"order": 20,
|
||||||
"action": {
|
"action": {
|
||||||
"type": "view",
|
"type": "view",
|
||||||
"path": "streamlit/settings"
|
"path": "streamlit/settings"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user