- apps.js: ZIP file upload with tree view file selection - Client-side ZIP parsing for file list preview - Interactive tree with checkboxes for file selection - Select All / Deselect All / Python Only buttons - Supports both .py and .zip file uploads - api.js: Added previewZip() and uploadZip() RPC methods - luci.streamlit RPCD: - preview_zip: List ZIP contents with file sizes - upload_zip: Extract selected files to app directory - Automatic main .py file detection and registration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
898 lines
27 KiB
JavaScript
898 lines
27 KiB
JavaScript
'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');
|
|
});
|
|
}
|
|
});
|