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';
|
||||
'require view';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require streamlit.api as api';
|
||||
|
||||
return view.extend({
|
||||
configData: null,
|
||||
config: {},
|
||||
|
||||
load: function() {
|
||||
return api.getConfig().then(function(config) {
|
||||
return config;
|
||||
return api.getConfig().then(function(c) {
|
||||
return c || {};
|
||||
});
|
||||
},
|
||||
|
||||
render: function(configData) {
|
||||
render: function(config) {
|
||||
var self = this;
|
||||
this.configData = configData || {};
|
||||
this.config = config;
|
||||
var main = config.main || {};
|
||||
var server = config.server || {};
|
||||
|
||||
// Inject CSS
|
||||
var cssLink = E('link', {
|
||||
'rel': 'stylesheet',
|
||||
'type': 'text/css',
|
||||
'href': L.resource('streamlit/dashboard.css')
|
||||
});
|
||||
return E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, _('Streamlit Settings')),
|
||||
|
||||
var container = E('div', { 'class': 'streamlit-dashboard' }, [
|
||||
cssLink,
|
||||
this.renderHeader(),
|
||||
E('div', { 'class': 'st-main-grid' }, [
|
||||
this.renderMainSettings(),
|
||||
this.renderServerSettings()
|
||||
])
|
||||
]);
|
||||
// Main Settings
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Service')),
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
renderHeader: function() {
|
||||
var self = this;
|
||||
|
||||
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': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Enabled')),
|
||||
E('div', { 'class': 'cbi-value-field' },
|
||||
E('select', { 'id': 'cfg-enabled', 'class': 'cbi-input-select' }, [
|
||||
E('option', { 'value': '1', 'selected': main.enabled == '1' || main.enabled === true }, _('Yes')),
|
||||
E('option', { 'value': '0', 'selected': main.enabled == '0' || main.enabled === false }, _('No'))
|
||||
])
|
||||
)
|
||||
]),
|
||||
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() {
|
||||
var config = this.configData.main || {};
|
||||
var isEnabled = config.enabled === true || config.enabled === 1 || config.enabled === '1';
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('HTTP Port')),
|
||||
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
|
||||
var memLimit = config.memory_limit || '1024M';
|
||||
if (memLimit === '1G') memLimit = '1024M';
|
||||
if (memLimit === '2G') memLimit = '2048M';
|
||||
if (memLimit === '4G') memLimit = '4096M';
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Listen Address')),
|
||||
E('div', { 'class': 'cbi-value-field' },
|
||||
E('input', { 'type': 'text', 'id': 'cfg-host', 'class': 'cbi-input-text',
|
||||
'value': main.http_host || '0.0.0.0' })
|
||||
)
|
||||
]),
|
||||
|
||||
return E('div', { 'class': 'st-card' }, [
|
||||
E('div', { 'class': 'st-card-header' }, [
|
||||
E('div', { 'class': 'st-card-title' }, [
|
||||
E('span', {}, '\uD83D\uDD27'),
|
||||
' ' + _('Main Settings')
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Data Path')),
|
||||
E('div', { 'class': 'cbi-value-field' },
|
||||
E('input', { 'type': 'text', 'id': 'cfg-path', 'class': 'cbi-input-text',
|
||||
'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() {
|
||||
var config = this.configData.server || {};
|
||||
// Server Settings
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Server Options')),
|
||||
|
||||
// Normalize boolean values (can be true/false boolean or "true"/"false" string)
|
||||
var isHeadless = config.headless === true || config.headless === 'true';
|
||||
var gatherStats = config.browser_gather_usage_stats === true || config.browser_gather_usage_stats === 'true';
|
||||
var themeBase = config.theme_base || 'dark';
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Headless')),
|
||||
E('div', { 'class': 'cbi-value-field' },
|
||||
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': 'st-card-header' }, [
|
||||
E('div', { 'class': 'st-card-title' }, [
|
||||
E('span', {}, '\uD83C\uDFA8'),
|
||||
' ' + _('Server & Theme')
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Theme')),
|
||||
E('div', { 'class': 'cbi-value-field' },
|
||||
E('select', { 'id': 'cfg-theme', 'class': 'cbi-input-select' }, [
|
||||
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' }, [
|
||||
E('label', { 'class': 'st-form-label' }, _('Headless Mode')),
|
||||
E('select', {
|
||||
'class': 'st-form-input',
|
||||
'id': 'cfg-headless',
|
||||
'style': 'height: 42px;'
|
||||
}, [
|
||||
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.')
|
||||
])
|
||||
])
|
||||
|
||||
// Save button
|
||||
E('div', { 'class': 'cbi-page-actions' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() { self.save(); }
|
||||
}, _('Save & Apply'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
saveSettings: function() {
|
||||
var self = this;
|
||||
|
||||
var config = {
|
||||
save: function() {
|
||||
var cfg = {
|
||||
enabled: document.getElementById('cfg-enabled').value,
|
||||
http_port: parseInt(document.getElementById('cfg-http_port').value, 10),
|
||||
http_host: document.getElementById('cfg-http_host').value,
|
||||
data_path: document.getElementById('cfg-data_path').value,
|
||||
memory_limit: document.getElementById('cfg-memory_limit').value,
|
||||
active_app: document.getElementById('cfg-active_app').value,
|
||||
http_port: document.getElementById('cfg-port').value,
|
||||
http_host: document.getElementById('cfg-host').value,
|
||||
data_path: document.getElementById('cfg-path').value,
|
||||
memory_limit: document.getElementById('cfg-memory').value,
|
||||
active_app: document.getElementById('cfg-app').value,
|
||||
headless: document.getElementById('cfg-headless').value,
|
||||
browser_gather_usage_stats: document.getElementById('cfg-gather_stats').value,
|
||||
theme_base: document.getElementById('cfg-theme_base').value,
|
||||
theme_primary_color: document.getElementById('cfg-theme_primary').value
|
||||
browser_gather_usage_stats: 'false',
|
||||
theme_base: document.getElementById('cfg-theme').value,
|
||||
theme_primary_color: document.getElementById('cfg-color').value
|
||||
};
|
||||
|
||||
api.saveConfig(config).then(function(result) {
|
||||
if (result && result.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Settings saved successfully')), 'success');
|
||||
api.saveConfig(cfg).then(function(r) {
|
||||
if (r && r.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Settings saved')), 'info');
|
||||
} 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}
|
||||
}
|
||||
},
|
||||
"admin/services/streamlit/overview": {
|
||||
"title": "Overview",
|
||||
"admin/services/streamlit/dashboard": {
|
||||
"title": "Dashboard",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "streamlit/overview"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"path": "streamlit/dashboard"
|
||||
}
|
||||
},
|
||||
"admin/services/streamlit/settings": {
|
||||
"title": "Settings",
|
||||
"order": 40,
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "streamlit/settings"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user