'use strict'; 'require view'; 'require ui'; 'require poll'; 'require streamlit.api as api'; 'require secubox/kiss-theme'; return view.extend({ status: {}, apps: [], instances: [], activeApp: '', giteaConfig: {}, load: function() { return this.refresh(); }, refresh: function() { var self = this; return Promise.all([ api.getStatus(), api.listApps(), api.listInstances(), api.getGiteaConfig().catch(function() { return {}; }) ]).then(function(r) { self.status = r[0] || {}; self.apps = (r[1] && r[1].apps) || []; self.activeApp = (r[1] && r[1].active_app) || ''; self.instances = r[2] || []; self.giteaConfig = r[3] || {}; }); }, 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 with multi-instance support')), // 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)) ]), // Instances Section E('div', { 'class': 'cbi-section', 'id': 'instances-section' }, [ E('h3', {}, _('Running Instances')), E('div', { 'id': 'instances-table' }, this.renderInstancesTable()) ]), // Add Instance Section E('div', { 'class': 'cbi-section' }, [ E('h3', {}, _('Add Instance')), this.renderAddInstanceForm() ]), // 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' }, _('File')), E('div', { 'class': 'cbi-value-field' }, [ E('input', { 'type': 'file', 'id': 'upload-file', 'accept': '.py,.zip' }), E('button', { 'class': 'cbi-button cbi-button-action', 'style': 'margin-left: 8px', 'click': function() { self.uploadApp(); } }, _('Upload')) ]), E('div', { 'class': 'cbi-value-description' }, _('Accepts .py files or .zip archives')) ]) ]), // Gitea Section E('div', { 'class': 'cbi-section' }, [ E('h3', {}, _('Clone from Gitea')), this.renderGiteaSection() ]) ]); poll.add(function() { return self.refresh().then(function() { self.updateStatus(); }); }, 5); return KissTheme.wrap(view, 'admin/secubox/services/streamlit/dashboard'); }, 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; }, renderInstancesTable: function() { var self = this; var instances = this.instances; if (!instances.length) { return E('em', {}, _('No instances configured. Add one below.')); } var rows = instances.map(function(inst) { var statusIcon = inst.enabled ? E('span', { 'style': 'color:#0a0' }, '\u25CF') : E('span', { 'style': 'color:#999' }, '\u25CB'); return E('tr', {}, [ E('td', {}, [ E('strong', {}, inst.id), inst.name && inst.name !== inst.id ? E('span', { 'style': 'color:#666; margin-left:4px' }, '(' + inst.name + ')') : '' ]), E('td', {}, inst.app || '-'), E('td', {}, ':' + inst.port), E('td', { 'style': 'text-align:center' }, [ statusIcon, E('span', { 'style': 'margin-left:4px' }, inst.enabled ? _('Enabled') : _('Disabled')) ]), E('td', {}, [ E('button', { 'class': 'cbi-button', 'click': function() { self.renameInstance(inst.id, inst.name); } }, _('Rename')), inst.enabled ? E('button', { 'class': 'cbi-button', 'style': 'margin-left: 4px', 'click': function() { self.disableInstance(inst.id); } }, _('Disable')) : E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 4px', 'click': function() { self.enableInstance(inst.id); } }, _('Enable')), E('button', { 'class': 'cbi-button cbi-button-remove', 'style': 'margin-left: 4px', 'click': function() { self.deleteInstance(inst.id); } }, _('Delete')) ]) ]); }); return E('table', { 'class': 'table cbi-section-table' }, [ E('tr', { 'class': 'tr table-titles' }, [ E('th', { 'class': 'th' }, _('ID')), E('th', { 'class': 'th' }, _('App')), E('th', { 'class': 'th' }, _('Port')), E('th', { 'class': 'th', 'style': 'text-align:center' }, _('Status')), E('th', { 'class': 'th' }, _('Actions')) ]) ].concat(rows)); }, renderAddInstanceForm: function() { var self = this; var appOptions = [E('option', { 'value': '' }, _('-- Select App --'))]; this.apps.forEach(function(app) { var appId = app.id || app.name; var label = app.name !== appId ? app.name + ' (' + appId + ')' : app.name; appOptions.push(E('option', { 'value': appId }, label)); }); // Calculate next available port var usedPorts = this.instances.map(function(i) { return i.port; }); var nextPort = 8501; while (usedPorts.indexOf(nextPort) !== -1) { nextPort++; } return E('div', {}, [ E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Instance ID')), E('div', { 'class': 'cbi-value-field' }, E('input', { 'type': 'text', 'id': 'new-inst-id', 'class': 'cbi-input-text', 'placeholder': _('myapp') }) ) ]), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('App')), E('div', { 'class': 'cbi-value-field' }, E('select', { 'id': 'new-inst-app', 'class': 'cbi-input-select' }, appOptions) ) ]), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Port')), E('div', { 'class': 'cbi-value-field' }, E('input', { 'type': 'number', 'id': 'new-inst-port', 'class': 'cbi-input-text', 'value': nextPort, 'min': '1024', 'max': '65535' }) ) ]), E('div', { 'class': 'cbi-page-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { self.addInstance(); } }, _('Add Instance')) ]) ]); }, 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 appId = app.id || app.name; var isActive = appId === self.activeApp; return E('tr', { 'class': isActive ? 'cbi-rowstyle-2' : '' }, [ E('td', {}, [ E('strong', {}, app.name), app.id && app.id !== app.name ? E('span', { 'style': 'color:#666; margin-left:4px' }, '(' + app.id + ')') : '', isActive ? E('span', { 'style': 'color:#0a0; margin-left:8px' }, _('(active)')) : '' ]), E('td', {}, app.path ? app.path.split('/').pop() : '-'), E('td', {}, [ E('button', { 'class': 'cbi-button', 'click': function() { self.editApp(appId); } }, _('Edit')), E('button', { 'class': 'cbi-button cbi-button-action', 'style': 'margin-left: 4px', 'click': function() { self.reuploadApp(appId); } }, _('Reupload')), E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 4px', 'click': function() { self.emancipateApp(appId); } }, _('Emancipate')), E('button', { 'class': 'cbi-button', 'style': 'margin-left: 4px', 'click': function() { self.renameApp(appId, app.name); } }, _('Rename')), !isActive ? E('button', { 'class': 'cbi-button cbi-button-action', 'style': 'margin-left: 4px', 'click': function() { self.activateApp(appId); } }, _('Activate')) : '', E('button', { 'class': 'cbi-button cbi-button-remove', 'style': 'margin-left: 4px', 'click': function() { self.deleteApp(appId); } }, _('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)); }, renderGiteaSection: function() { var self = this; var cfg = this.giteaConfig; var enabled = cfg.enabled; return E('div', {}, [ E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Status')), E('div', { 'class': 'cbi-value-field' }, enabled ? E('span', { 'style': 'color:#0a0' }, _('Configured')) : E('span', { 'style': 'color:#999' }, _('Not configured')) ) ]), enabled ? E('div', {}, [ E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Repository')), E('div', { 'class': 'cbi-value-field' }, E('input', { 'type': 'text', 'id': 'gitea-repo', 'class': 'cbi-input-text', 'placeholder': _('owner/repo or full URL') }) ) ]), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('App Name')), E('div', { 'class': 'cbi-value-field' }, E('input', { 'type': 'text', 'id': 'gitea-name', 'class': 'cbi-input-text', 'placeholder': _('myapp') }) ) ]), E('div', { 'class': 'cbi-page-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { self.giteaClone(); } }, _('Clone')) ]) ]) : E('div', { 'class': 'cbi-value-description' }, _('Configure Gitea in Settings to enable cloning')) ]); }, updateStatus: function() { var s = this.status; var statusEl = document.getElementById('svc-status'); var activeEl = document.getElementById('active-app'); var appsEl = document.getElementById('apps-table'); var instancesEl = document.getElementById('instances-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()); } if (instancesEl) { instancesEl.innerHTML = ''; instancesEl.appendChild(this.renderInstancesTable()); } }, 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')) ]) ]); }, addInstance: function() { var self = this; var id = document.getElementById('new-inst-id').value.trim(); var app = document.getElementById('new-inst-app').value; var port = parseInt(document.getElementById('new-inst-port').value, 10); if (!id) { 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; } api.addInstance(id, id, app, port).then(function(r) { if (r && r.success) { ui.addNotification(null, E('p', {}, _('Instance added: ') + id), 'success'); document.getElementById('new-inst-id').value = ''; document.getElementById('new-inst-app').value = ''; self.refresh().then(function() { self.updateStatus(); }); } else { ui.addNotification(null, E('p', {}, r.message || _('Failed to add instance')), 'error'); } }); }, enableInstance: function(id) { var self = this; api.enableInstance(id).then(function(r) { if (r && r.success) { ui.addNotification(null, E('p', {}, _('Instance enabled: ') + id), 'success'); self.refresh().then(function() { self.updateStatus(); }); } else { ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); } }); }, disableInstance: function(id) { var self = this; api.disableInstance(id).then(function(r) { if (r && r.success) { ui.addNotification(null, E('p', {}, _('Instance disabled: ') + id), 'success'); self.refresh().then(function() { self.updateStatus(); }); } else { ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); } }); }, deleteInstance: function(id) { var self = this; ui.showModal(_('Confirm'), [ E('p', {}, _('Delete instance: ') + id + '?'), 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.removeInstance(id).then(function(r) { if (r && r.success) { ui.addNotification(null, E('p', {}, _('Instance deleted')), 'info'); } self.refresh().then(function() { self.updateStatus(); }); }); } }, _('Delete')) ]) ]); }, activateApp: function(name) { var self = this; var hasInstance = this.instances.some(function(inst) { return inst.app === name; }); api.setActiveApp(name).then(function(r) { if (r && r.success) { if (!hasInstance) { // Auto-create instance with next available port var usedPorts = self.instances.map(function(i) { return i.port; }); var port = 8501; while (usedPorts.indexOf(port) !== -1) { port++; } return api.addInstance(name, name, name, port).then(function() { ui.addNotification(null, E('p', {}, _('App activated with new instance on port ') + port), 'info'); return api.restart(); }); } else { ui.addNotification(null, E('p', {}, _('App activated: ') + name), 'info'); return api.restart(); } } }).then(function() { self.refresh().then(function() { self.updateStatus(); }); }); }, 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'); } else { ui.addNotification(null, E('p', {}, (r && r.message) || _('Delete failed')), 'error'); } self.refresh().then(function() { self.updateStatus(); }); }); } }, _('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|zip)$/, '').replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+|_+$/g, ''); var isZip = file.name.endsWith('.zip'); var reader = new FileReader(); reader.onerror = function() { ui.addNotification(null, E('p', {}, _('Failed to read file')), 'error'); }; reader.onload = function(e) { var bytes = new Uint8Array(e.target.result); var chunks = []; for (var i = 0; i < bytes.length; i += 8192) { chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192))); } var content = btoa(chunks.join('')); // Stop polling to prevent RPC batch conflicts poll.stop(); // Use chunked upload for files > 40KB (uhttpd has 64KB JSON body limit) var useChunked = content.length > 40000; setTimeout(function() { var uploadPromise; if (useChunked && !isZip) { // For chunked .py files: upload chunks, test, then finalize var CHUNK_SIZE = 40000; var chunkList = []; for (var i = 0; i < content.length; i += CHUNK_SIZE) { chunkList.push(content.substring(i, i + CHUNK_SIZE)); } // Upload all chunks first var chunkPromise = Promise.resolve(); chunkList.forEach(function(chunk, idx) { chunkPromise = chunkPromise.then(function() { return api.uploadChunk(name, chunk, idx); }); }); uploadPromise = chunkPromise.then(function() { // After chunks uploaded, test the pending upload return api.testUpload(name); }).then(function(testResult) { if (testResult && !testResult.valid) { // Test failed - show errors and don't finalize poll.start(); var errMsg = testResult.errors || _('Invalid Python file'); ui.addNotification(null, E('p', {}, _('Validation failed: ') + errMsg), 'error'); if (testResult.warnings) { ui.addNotification(null, E('p', {}, _('Warnings: ') + testResult.warnings), 'warning'); } return { success: false, message: errMsg }; } // Test passed or container not running - show warnings and proceed if (testResult && testResult.warnings) { ui.addNotification(null, E('p', {}, _('Warnings: ') + testResult.warnings), 'warning'); } // Finalize upload return api.uploadFinalize(name, '0'); }); } else if (useChunked && isZip) { // ZIP files don't get syntax tested uploadPromise = api.chunkedUpload(name, content, true); } else if (isZip) { uploadPromise = api.uploadZip(name, content, null); } else { // Small .py file - direct upload (no pre-test for non-chunked) uploadPromise = api.uploadApp(name, content); } uploadPromise.then(function(r) { poll.start(); if (r && r.success) { ui.addNotification(null, E('p', {}, _('App uploaded: ') + name), 'success'); fileInput.value = ''; self.refresh().then(function() { self.updateStatus(); }); } else { var msg = (r && r.message) ? r.message : _('Upload failed'); ui.addNotification(null, E('p', {}, msg), 'error'); } }).catch(function(err) { poll.start(); ui.addNotification(null, E('p', {}, _('Upload error: ') + (err.message || err)), 'error'); }); }, 10); }; reader.readAsArrayBuffer(file); }, reuploadApp: function(id) { var self = this; // Create hidden file input var fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.py,.zip'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); fileInput.onchange = function() { if (!fileInput.files.length) { document.body.removeChild(fileInput); return; } var file = fileInput.files[0]; var isZip = file.name.endsWith('.zip'); var reader = new FileReader(); reader.onerror = function() { document.body.removeChild(fileInput); ui.addNotification(null, E('p', {}, _('Failed to read file')), 'error'); }; reader.onload = function(e) { document.body.removeChild(fileInput); var bytes = new Uint8Array(e.target.result); var chunks = []; for (var i = 0; i < bytes.length; i += 8192) { chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192))); } var content = btoa(chunks.join('')); ui.showModal(_('Reuploading...'), [ E('p', { 'class': 'spinning' }, _('Uploading ') + file.name + ' to ' + id + '...') ]); poll.stop(); var useChunked = content.length > 40000; var uploadPromise; if (useChunked) { uploadPromise = api.chunkedUpload(id, content, isZip); } else if (isZip) { uploadPromise = api.uploadZip(id, content, null); } else { uploadPromise = api.uploadApp(id, content); } uploadPromise.then(function(r) { poll.start(); if (r && r.success) { // Restart service to reload the updated file ui.showModal(_('Restarting...'), [ E('p', { 'class': 'spinning' }, _('Restarting Streamlit to apply changes...')) ]); return api.restart().then(function() { ui.hideModal(); ui.addNotification(null, E('p', {}, _('App reuploaded and service restarted: ') + id), 'success'); self.refresh().then(function() { self.updateStatus(); }); }); } else { ui.hideModal(); ui.addNotification(null, E('p', {}, (r && r.message) || _('Reupload failed')), 'error'); } }).catch(function(err) { poll.start(); ui.hideModal(); ui.addNotification(null, E('p', {}, _('Reupload error: ') + (err.message || err)), 'error'); }); }; reader.readAsArrayBuffer(file); }; // Trigger file selection fileInput.click(); }, editApp: function(id) { var self = this; ui.showModal(_('Loading...'), [ E('p', { 'class': 'spinning' }, _('Loading source code...')) ]); api.getSource(id).then(function(r) { if (!r || !r.content) { ui.hideModal(); ui.addNotification(null, E('p', {}, r.message || _('Failed to load source')), 'error'); return; } // Decode base64 content var source; try { source = atob(r.content); } catch (e) { ui.hideModal(); ui.addNotification(null, E('p', {}, _('Failed to decode source')), 'error'); return; } ui.hideModal(); ui.showModal(_('Edit App: ') + id, [ E('div', { 'style': 'margin-bottom: 8px' }, [ E('small', { 'style': 'color:#666' }, r.path) ]), E('textarea', { 'id': 'edit-source', 'style': 'width:100%; height:400px; font-family:monospace; font-size:12px; tab-size:4;', 'spellcheck': 'false' }, source), E('div', { 'class': 'right', 'style': 'margin-top: 12px' }, [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px', 'click': function() { var newSource = document.getElementById('edit-source').value; var encoded = btoa(newSource); ui.hideModal(); ui.showModal(_('Saving...'), [ E('p', { 'class': 'spinning' }, _('Saving source code...')) ]); api.saveSource(id, encoded).then(function(sr) { ui.hideModal(); if (sr && sr.success) { ui.addNotification(null, E('p', {}, _('Source saved')), 'success'); } else { ui.addNotification(null, E('p', {}, sr.message || _('Save failed')), 'error'); } }); } }, _('Save')) ]) ]); }); }, emancipateApp: function(id) { var self = this; // First check if app has an instance var hasInstance = this.instances.some(function(inst) { return inst.app === id; }); if (!hasInstance) { ui.addNotification(null, E('p', {}, _('Create an instance first before emancipating. The instance port is needed for exposure.')), 'warning'); return; } // Get current emancipation status api.getEmancipation(id).then(function(r) { var currentDomain = (r && r.domain) || ''; var isEmancipated = r && r.emancipated; ui.showModal(_('Emancipate App: ') + id, [ isEmancipated ? E('div', { 'style': 'margin-bottom: 12px; padding: 8px; background: #e8f5e9; border-radius: 4px' }, [ E('strong', { 'style': 'color: #2e7d32' }, _('Already emancipated')), E('br'), E('span', {}, _('Domain: ') + currentDomain) ]) : '', E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Domain')), E('div', { 'class': 'cbi-value-field' }, E('input', { 'type': 'text', 'id': 'emancipate-domain', 'class': 'cbi-input-text', 'value': currentDomain, 'placeholder': _('app.gk2.secubox.in') }) ), E('div', { 'class': 'cbi-value-description' }, _('Leave empty for auto-detection from Vortex wildcard domain')) ]), E('div', { 'style': 'margin: 12px 0; padding: 12px; background: #f5f5f5; border-radius: 4px' }, [ E('strong', {}, _('KISS ULTIME MODE will:')), E('ul', { 'style': 'margin: 8px 0 0 20px' }, [ E('li', {}, _('Register DNS A record')), E('li', {}, _('Publish to Vortex mesh')), E('li', {}, _('Create HAProxy vhost + backend')), E('li', {}, _('Issue SSL certificate via ACME')), E('li', {}, _('Reload HAProxy with zero downtime')) ]) ]), E('div', { 'class': 'right' }, [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px', 'click': function() { var domain = document.getElementById('emancipate-domain').value.trim(); ui.hideModal(); ui.showModal(_('Emancipating...'), [ E('p', { 'class': 'spinning' }, _('Running KISS ULTIME MODE workflow...')) ]); api.emancipate(id, domain).then(function(er) { ui.hideModal(); if (er && er.success) { var msg = _('Emancipation started for ') + id; if (er.domain) msg += ' at ' + er.domain; ui.addNotification(null, E('p', {}, msg), 'success'); self.refresh().then(function() { self.updateStatus(); }); } else { ui.addNotification(null, E('p', {}, er.message || _('Emancipation failed')), 'error'); } }); } }, _('Emancipate')) ]) ]); }); }, renameApp: function(id, currentName) { var self = this; if (!currentName) currentName = id; ui.showModal(_('Rename App'), [ E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Name')), E('div', { 'class': 'cbi-value-field' }, E('input', { 'type': 'text', 'id': 'rename-input', 'class': 'cbi-input-text', 'value': currentName })) ]), E('div', { 'class': 'right' }, [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px', 'click': function() { var newName = document.getElementById('rename-input').value.trim(); if (!newName) return; ui.hideModal(); api.renameApp(id, newName).then(function(r) { if (r && r.success) ui.addNotification(null, E('p', {}, _('App renamed')), 'info'); self.refresh().then(function() { self.updateStatus(); }); }); } }, _('Save')) ]) ]); }, renameInstance: function(id, currentName) { var self = this; ui.showModal(_('Rename Instance'), [ E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Name')), E('div', { 'class': 'cbi-value-field' }, E('input', { 'type': 'text', 'id': 'rename-input', 'class': 'cbi-input-text', 'value': currentName || id })) ]), E('div', { 'class': 'right' }, [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px', 'click': function() { var newName = document.getElementById('rename-input').value.trim(); if (!newName) return; ui.hideModal(); api.renameInstance(id, newName).then(function(r) { if (r && r.success) ui.addNotification(null, E('p', {}, _('Instance renamed')), 'info'); self.refresh().then(function() { self.updateStatus(); }); }); } }, _('Save')) ]) ]); }, giteaClone: function() { var self = this; var repo = document.getElementById('gitea-repo').value.trim(); var name = document.getElementById('gitea-name').value.trim(); if (!repo) { ui.addNotification(null, E('p', {}, _('Please enter a repository')), 'error'); return; } if (!name) { // Extract name from repo name = repo.split('/').pop().replace(/\.git$/, ''); } ui.showModal(_('Cloning'), [ E('p', { 'class': 'spinning' }, _('Cloning ') + repo + '...') ]); api.giteaClone(name, repo).then(function(r) { ui.hideModal(); if (r && r.success) { ui.addNotification(null, E('p', {}, _('Clone started: ') + name), 'success'); document.getElementById('gitea-repo').value = ''; document.getElementById('gitea-name').value = ''; setTimeout(function() { self.refresh().then(function() { self.updateStatus(); }); }, 3000); } else { ui.addNotification(null, E('p', {}, r.message || _('Clone failed')), 'error'); } }); } });