Metabolizer Blog Pipeline - integrated CMS for SecuBox: - Gitea: Mirror GitHub repos, store blog content - Streamlit: CMS app with markdown editor and live preview - HexoJS: Static site generator (clean → generate → publish) - Webhooks: Auto-rebuild on git push - Portal: Static blog served at /blog/ Pipeline: Edit in Streamlit CMS → Push to Gitea → Build with Hexo → Publish Packages: - secubox-app-streamlit: Streamlit server with LXC container - luci-app-streamlit: LuCI dashboard for Streamlit apps - secubox-app-metabolizer: CMS pipeline orchestrator CMS Features: - Two-column markdown editor with live preview - YAML front matter editor - Post management (drafts, publish, unpublish) - Media library with image upload - Git sync and Hexo build controls - Cyberpunk theme styling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
364 lines
10 KiB
JavaScript
364 lines
10 KiB
JavaScript
'use strict';
|
|
'require view';
|
|
'require ui';
|
|
'require dom';
|
|
'require poll';
|
|
'require streamlit.api as api';
|
|
|
|
return view.extend({
|
|
appsData: null,
|
|
statusData: null,
|
|
|
|
load: function() {
|
|
return this.refreshData();
|
|
},
|
|
|
|
refreshData: function() {
|
|
var self = this;
|
|
return Promise.all([
|
|
api.listApps(),
|
|
api.getStatus()
|
|
]).then(function(results) {
|
|
self.appsData = results[0] || {};
|
|
self.statusData = results[1] || {};
|
|
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'))
|
|
])
|
|
])
|
|
]);
|
|
},
|
|
|
|
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;
|
|
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')) : ''
|
|
]),
|
|
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')) : '',
|
|
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 file here or click to browse')),
|
|
E('p', { 'style': 'font-size: 12px; color: #64748b;' }, _('Supported: Python (.py) files'))
|
|
])
|
|
]),
|
|
E('input', {
|
|
'type': 'file',
|
|
'id': 'upload-file',
|
|
'accept': '.py',
|
|
'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;
|
|
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')) : ''
|
|
]),
|
|
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')) : '',
|
|
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;
|
|
|
|
if (!file.name.endsWith('.py')) {
|
|
ui.addNotification(null, E('p', {}, _('Please select a Python (.py) file')), 'error');
|
|
return;
|
|
}
|
|
|
|
var nameInput = document.getElementById('upload-name');
|
|
if (!nameInput.value) {
|
|
// Auto-fill name from filename
|
|
nameInput.value = file.name.replace('.py', '');
|
|
}
|
|
|
|
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) + ')'));
|
|
|
|
// Add upload button
|
|
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')]));
|
|
},
|
|
|
|
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) {
|
|
var content = btoa(e.target.result);
|
|
|
|
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.readAsText(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'))
|
|
])
|
|
]);
|
|
}
|
|
});
|