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:
CyberMind-FR 2026-02-01 07:17:20 +01:00
parent 280c6a08d9
commit 5317f37e7a
7 changed files with 458 additions and 2454 deletions

View File

@ -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');
});
}
});

View File

@ -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);
}
});

View 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');
});
}
});

View File

@ -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')));
}
}
});

View File

@ -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'))
])
]);
}
});

View File

@ -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');
});
}
});

View File

@ -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"