From bc5bd8d8ce8c425ea50a7d5402ce94c69ed5872b Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Wed, 28 Jan 2026 06:40:57 +0100 Subject: [PATCH] feat(haproxy,service-registry): Add async cert workflow and fix QR codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HAProxy Certificates: - Add async certificate request API (start_cert_request, get_cert_task) - Non-blocking ACME requests with background processing - Real-time progress tracking with phases (starting โ†’ validating โ†’ requesting โ†’ verifying โ†’ complete) - Add staging vs production mode toggle for ACME - New modern UI with visual progress indicators - Task persistence and polling support Service Registry: - Fix QR codes using api.qrserver.com (Google Charts deprecated) - Fix form prefill with proper _new section selectors - Add change event dispatch for LuCI form bindings - Update landing page generator with working QR API Co-Authored-By: Claude Opus 4.5 --- .../resources/view/haproxy/certificates.js | 478 +++++++++--- .../root/usr/libexec/rpcd/luci.haproxy | 207 +++++- .../share/rpcd/acl.d/luci-app-haproxy.json | 6 + .../view/service-registry/overview.js | 688 +++++++----------- .../view/service-registry/publish.js | 212 ++++-- .../root/usr/sbin/secubox-landing-gen | 81 +-- 6 files changed, 1038 insertions(+), 634 deletions(-) diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/certificates.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/certificates.js index 28a6ebf7..2c17c9be 100644 --- a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/certificates.js +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/certificates.js @@ -2,158 +2,371 @@ 'require view'; 'require dom'; 'require ui'; +'require rpc'; 'require haproxy.api as api'; +// Async certificate API +var callStartCertRequest = rpc.declare({ + object: 'luci.haproxy', + method: 'start_cert_request', + params: ['domain', 'staging'] +}); + +var callGetCertTask = rpc.declare({ + object: 'luci.haproxy', + method: 'get_cert_task', + params: ['task_id'] +}); + +var callListCertTasks = rpc.declare({ + object: 'luci.haproxy', + method: 'list_cert_tasks' +}); + return view.extend({ + pollInterval: null, + currentTaskId: null, + load: function() { - return api.listCertificates(); + return Promise.all([ + api.listCertificates(), + callListCertTasks().catch(function() { return { tasks: [] }; }) + ]); }, - render: function(certificates) { + render: function(data) { var self = this; - certificates = certificates || []; + var certificates = data[0] || []; + var tasks = (data[1] && data[1].tasks) || []; + + // Filter active tasks + var activeTasks = tasks.filter(function(t) { + return t.status === 'pending' || t.status === 'running'; + }); var view = E('div', { 'class': 'cbi-map' }, [ - E('h2', {}, 'SSL Certificates'), + E('style', {}, this.getStyles()), + E('h2', {}, '๐Ÿ”’ SSL Certificates'), E('p', {}, 'Manage SSL/TLS certificates for your domains. Request free certificates via ACME or import your own.'), + // Active tasks (if any) + activeTasks.length > 0 ? this.renderActiveTasks(activeTasks) : null, + // Request certificate section - E('div', { 'class': 'haproxy-form-section' }, [ - E('h3', {}, 'Request Certificate (ACME/Let\'s Encrypt)'), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, 'Domain'), - E('div', { 'class': 'cbi-value-field' }, [ + E('div', { 'class': 'cert-section' }, [ + E('h3', {}, '๐Ÿ“œ Request Certificate (ACME/Let\'s Encrypt)'), + E('div', { 'class': 'cert-form' }, [ + E('div', { 'class': 'cert-form-row' }, [ + E('label', {}, 'Domain'), E('input', { 'type': 'text', 'id': 'acme-domain', 'class': 'cbi-input-text', 'placeholder': 'example.com' }), - E('p', { 'class': 'cbi-value-description' }, - 'Domain must point to this server. ACME challenge will run on port 80.') - ]) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, ''), - E('div', { 'class': 'cbi-value-field' }, [ + E('span', { 'class': 'cert-hint' }, 'Domain must point to this server') + ]), + E('div', { 'class': 'cert-form-row' }, [ + E('label', {}, 'Mode'), + E('div', { 'class': 'cert-mode-toggle' }, [ + E('label', { 'class': 'cert-mode-option' }, [ + E('input', { + 'type': 'radio', + 'name': 'acme-mode', + 'value': 'production', + 'checked': true + }), + E('span', { 'class': 'cert-mode-label cert-mode-prod' }, '๐Ÿญ Production'), + E('span', { 'class': 'cert-mode-desc' }, 'Publicly trusted certificate') + ]), + E('label', { 'class': 'cert-mode-option' }, [ + E('input', { + 'type': 'radio', + 'name': 'acme-mode', + 'value': 'staging' + }), + E('span', { 'class': 'cert-mode-label cert-mode-staging' }, '๐Ÿงช Staging'), + E('span', { 'class': 'cert-mode-desc' }, 'Test certificate (not trusted)') + ]) + ]) + ]), + E('div', { 'class': 'cert-form-row' }, [ + E('label', {}, ''), E('button', { 'class': 'cbi-button cbi-button-apply', - 'click': function() { self.handleRequestCert(); } - }, 'Request Certificate') + 'id': 'btn-request-cert', + 'click': function() { self.handleRequestCertAsync(); } + }, '๐Ÿš€ Request Certificate') ]) + ]), + + // Progress container (hidden initially) + E('div', { 'id': 'cert-progress-container', 'class': 'cert-progress', 'style': 'display: none;' }, [ + E('div', { 'class': 'cert-progress-header' }, [ + E('span', { 'id': 'cert-progress-icon', 'class': 'cert-progress-icon' }, 'โณ'), + E('span', { 'id': 'cert-progress-domain', 'class': 'cert-progress-domain' }, ''), + E('span', { 'id': 'cert-progress-status', 'class': 'cert-status' }, '') + ]), + E('div', { 'class': 'cert-progress-phases' }, [ + E('div', { 'id': 'phase-starting', 'class': 'cert-phase' }, 'โฌœ Starting'), + E('div', { 'id': 'phase-validating', 'class': 'cert-phase' }, 'โฌœ DNS Validation'), + E('div', { 'id': 'phase-requesting', 'class': 'cert-phase' }, 'โฌœ ACME Request'), + E('div', { 'id': 'phase-verifying', 'class': 'cert-phase' }, 'โฌœ Verifying'), + E('div', { 'id': 'phase-complete', 'class': 'cert-phase' }, 'โฌœ Complete') + ]), + E('div', { 'id': 'cert-progress-message', 'class': 'cert-progress-message' }, '') ]) ]), // Import certificate section - E('div', { 'class': 'haproxy-form-section' }, [ - E('h3', {}, 'Import Certificate'), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, 'Domain'), - E('div', { 'class': 'cbi-value-field' }, [ + E('div', { 'class': 'cert-section' }, [ + E('h3', {}, '๐Ÿ“ฅ Import Certificate'), + E('div', { 'class': 'cert-form' }, [ + E('div', { 'class': 'cert-form-row' }, [ + E('label', {}, 'Domain'), E('input', { 'type': 'text', 'id': 'import-domain', 'class': 'cbi-input-text', 'placeholder': 'example.com' }) - ]) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, 'Certificate (PEM)'), - E('div', { 'class': 'cbi-value-field' }, [ + ]), + E('div', { 'class': 'cert-form-row' }, [ + E('label', {}, 'Certificate (PEM)'), E('textarea', { 'id': 'import-cert', 'class': 'cbi-input-textarea', - 'rows': '6', + 'rows': '4', 'placeholder': '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----' }) - ]) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, 'Private Key (PEM)'), - E('div', { 'class': 'cbi-value-field' }, [ + ]), + E('div', { 'class': 'cert-form-row' }, [ + E('label', {}, 'Private Key (PEM)'), E('textarea', { 'id': 'import-key', 'class': 'cbi-input-textarea', - 'rows': '6', + 'rows': '4', 'placeholder': '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----' }) - ]) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, ''), - E('div', { 'class': 'cbi-value-field' }, [ + ]), + E('div', { 'class': 'cert-form-row' }, [ + E('label', {}, ''), E('button', { 'class': 'cbi-button cbi-button-add', 'click': function() { self.handleImportCert(); } - }, 'Import Certificate') + }, '๐Ÿ“ฅ Import Certificate') ]) ]) ]), // Certificate list - E('div', { 'class': 'haproxy-form-section' }, [ - E('h3', {}, 'Installed Certificates (' + certificates.length + ')'), - E('div', { 'class': 'haproxy-cert-list' }, + E('div', { 'class': 'cert-section' }, [ + E('h3', {}, '๐Ÿ“‹ Installed Certificates (' + certificates.length + ')'), + E('div', { 'class': 'cert-list' }, certificates.length === 0 - ? E('p', { 'style': 'color: var(--text-color-medium, #666)' }, 'No certificates installed.') + ? E('p', { 'class': 'cert-empty' }, 'No certificates installed.') : certificates.map(function(cert) { - return E('div', { 'class': 'haproxy-cert-item', 'data-id': cert.id }, [ - E('div', {}, [ - E('div', { 'class': 'haproxy-cert-domain' }, cert.domain), - E('div', { 'class': 'haproxy-cert-type' }, - 'Type: ' + (cert.type === 'acme' ? 'ACME (auto-renew)' : 'Manual')) - ]), - E('div', {}, [ - E('span', { - 'class': 'haproxy-badge ' + (cert.enabled ? 'enabled' : 'disabled'), - 'style': 'margin-right: 8px' - }, cert.enabled ? 'Enabled' : 'Disabled'), - E('button', { - 'class': 'cbi-button cbi-button-remove', - 'click': function() { self.handleDeleteCert(cert); } - }, 'Delete') - ]) - ]); + return self.renderCertRow(cert); }) ) ]) ]); - // Add CSS - var style = E('style', {}, ` - @import url('/luci-static/resources/haproxy/dashboard.css'); - .cbi-input-textarea { - width: 100%; - font-family: monospace; - } - `); - view.insertBefore(style, view.firstChild); - return view; }, - handleRequestCert: function() { + renderActiveTasks: function(tasks) { + var self = this; + return E('div', { 'class': 'cert-section cert-active-tasks' }, [ + E('h3', {}, 'โณ Active Certificate Requests'), + E('div', { 'class': 'cert-task-list' }, + tasks.map(function(task) { + return E('div', { 'class': 'cert-task-item' }, [ + E('span', { 'class': 'cert-task-domain' }, task.domain), + E('span', { 'class': 'cert-task-phase' }, task.phase), + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': function() { self.resumeTaskPolling(task.task_id); } + }, '๐Ÿ‘๏ธ View Progress') + ]); + }) + ) + ]); + }, + + renderCertRow: function(cert) { + var self = this; + var isExpiringSoon = cert.expires_in && cert.expires_in < 30; + var typeIcon = cert.type === 'acme' ? '๐Ÿ”„' : '๐Ÿ“„'; + var statusIcon = cert.enabled ? 'โœ…' : 'โฌœ'; + + return E('div', { 'class': 'cert-row' }, [ + E('span', { 'class': 'cert-col-status' }, statusIcon), + E('span', { 'class': 'cert-col-domain' }, [ + E('strong', {}, cert.domain), + E('span', { 'class': 'cert-type-badge' }, typeIcon + ' ' + (cert.type === 'acme' ? 'ACME' : 'Manual')) + ]), + E('span', { 'class': 'cert-col-expiry ' + (isExpiringSoon ? 'cert-expiring' : '') }, + cert.expires ? '๐Ÿ“… ' + cert.expires : '-' + ), + E('span', { 'class': 'cert-col-issuer' }, cert.issuer || '-'), + E('span', { 'class': 'cert-col-action' }, [ + E('button', { + 'class': 'cert-btn cert-btn-delete', + 'title': 'Delete', + 'click': function() { self.handleDeleteCert(cert); } + }, '๐Ÿ—‘๏ธ') + ]) + ]); + }, + + handleRequestCertAsync: function() { + var self = this; var domain = document.getElementById('acme-domain').value.trim(); + var staging = document.querySelector('input[name="acme-mode"]:checked').value === 'staging'; if (!domain) { - ui.addNotification(null, E('p', {}, 'Domain is required'), 'error'); + ui.addNotification(null, E('p', {}, 'โŒ Domain is required'), 'error'); return; } - ui.showModal('Requesting Certificate', [ - E('p', { 'class': 'spinning' }, 'Requesting certificate for ' + domain + '...') - ]); + // Validate domain format + if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/.test(domain)) { + ui.addNotification(null, E('p', {}, 'โŒ Invalid domain format'), 'error'); + return; + } - return api.requestCertificate(domain).then(function(res) { - ui.hideModal(); - if (res.success) { - ui.addNotification(null, E('p', {}, res.message || 'Certificate requested')); - window.location.reload(); + // Show progress container + var progressContainer = document.getElementById('cert-progress-container'); + progressContainer.style.display = 'block'; + + // Update UI + document.getElementById('cert-progress-domain').textContent = domain; + document.getElementById('cert-progress-status').textContent = staging ? '๐Ÿงช STAGING' : '๐Ÿญ PRODUCTION'; + document.getElementById('cert-progress-status').className = 'cert-status ' + (staging ? 'cert-status-staging' : 'cert-status-prod'); + document.getElementById('cert-progress-message').textContent = 'Starting certificate request...'; + document.getElementById('btn-request-cert').disabled = true; + + // Reset phase indicators + ['starting', 'validating', 'requesting', 'verifying', 'complete'].forEach(function(phase) { + document.getElementById('phase-' + phase).className = 'cert-phase'; + document.getElementById('phase-' + phase).textContent = 'โฌœ ' + document.getElementById('phase-' + phase).textContent.substring(2); + }); + + // Start async request + callStartCertRequest(domain, staging).then(function(res) { + if (res.success && res.task_id) { + self.currentTaskId = res.task_id; + self.startPolling(); } else { - ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); + document.getElementById('cert-progress-icon').textContent = 'โŒ'; + document.getElementById('cert-progress-message').textContent = res.error || 'Failed to start request'; + document.getElementById('btn-request-cert').disabled = false; + } + }).catch(function(err) { + document.getElementById('cert-progress-icon').textContent = 'โŒ'; + document.getElementById('cert-progress-message').textContent = 'Error: ' + err.message; + document.getElementById('btn-request-cert').disabled = false; + }); + }, + + resumeTaskPolling: function(taskId) { + var self = this; + var progressContainer = document.getElementById('cert-progress-container'); + progressContainer.style.display = 'block'; + this.currentTaskId = taskId; + this.startPolling(); + }, + + startPolling: function() { + var self = this; + if (this.pollInterval) { + clearInterval(this.pollInterval); + } + + this.pollInterval = setInterval(function() { + self.pollTaskStatus(); + }, 2000); + + // Poll immediately + this.pollTaskStatus(); + }, + + stopPolling: function() { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + }, + + pollTaskStatus: function() { + var self = this; + if (!this.currentTaskId) return; + + callGetCertTask(this.currentTaskId).then(function(task) { + if (!task || task.error) { + self.stopPolling(); + return; + } + + // Update progress UI + self.updateProgressUI(task); + + // Stop polling if complete or failed + if (task.status === 'success' || task.status === 'failed') { + self.stopPolling(); + document.getElementById('btn-request-cert').disabled = false; + + if (task.status === 'success') { + ui.addNotification(null, E('p', {}, 'โœ… Certificate issued for ' + task.domain), 'info'); + setTimeout(function() { window.location.reload(); }, 2000); + } + } + }).catch(function() { + self.stopPolling(); + }); + }, + + updateProgressUI: function(task) { + var phaseIcons = { + 'pending': 'โณ', 'starting': '๐Ÿ”„', 'validating': '๐Ÿ”', + 'requesting': '๐Ÿ“ก', 'verifying': 'โœ”๏ธ', 'complete': 'โœ…' + }; + var phases = ['starting', 'validating', 'requesting', 'verifying', 'complete']; + var currentPhaseIndex = phases.indexOf(task.phase); + + // Update main icon + if (task.status === 'success') { + document.getElementById('cert-progress-icon').textContent = 'โœ…'; + } else if (task.status === 'failed') { + document.getElementById('cert-progress-icon').textContent = 'โŒ'; + } else { + document.getElementById('cert-progress-icon').textContent = phaseIcons[task.phase] || 'โณ'; + } + + // Update phase indicators + phases.forEach(function(phase, index) { + var el = document.getElementById('phase-' + phase); + var label = el.textContent.substring(2); + if (index < currentPhaseIndex) { + el.className = 'cert-phase cert-phase-done'; + el.textContent = 'โœ… ' + label; + } else if (index === currentPhaseIndex) { + el.className = 'cert-phase cert-phase-active'; + el.textContent = (task.status === 'failed' ? 'โŒ' : '๐Ÿ”„') + ' ' + label; + } else { + el.className = 'cert-phase'; + el.textContent = 'โฌœ ' + label; } }); + + // Update message + document.getElementById('cert-progress-message').textContent = task.message || ''; + + // Update domain if needed + if (task.domain) { + document.getElementById('cert-progress-domain').textContent = task.domain; + } }, handleImportCert: function() { @@ -162,46 +375,123 @@ return view.extend({ var key = document.getElementById('import-key').value.trim(); if (!domain || !cert || !key) { - ui.addNotification(null, E('p', {}, 'Domain, certificate and key are all required'), 'error'); + ui.addNotification(null, E('p', {}, 'โŒ Domain, certificate and key are all required'), 'error'); return; } + ui.showModal('๐Ÿ“ฅ Importing Certificate', [ + E('p', { 'class': 'spinning' }, 'Importing certificate for ' + domain + '...') + ]); + return api.importCertificate(domain, cert, key).then(function(res) { + ui.hideModal(); if (res.success) { - ui.addNotification(null, E('p', {}, res.message || 'Certificate imported')); + ui.addNotification(null, E('p', {}, 'โœ… ' + (res.message || 'Certificate imported'))); window.location.reload(); } else { - ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); + ui.addNotification(null, E('p', {}, 'โŒ Failed: ' + (res.error || 'Unknown error')), 'error'); } }); }, handleDeleteCert: function(cert) { - ui.showModal('Delete Certificate', [ + ui.showModal('๐Ÿ—‘๏ธ Delete Certificate', [ E('p', {}, 'Are you sure you want to delete the certificate for "' + cert.domain + '"?'), E('div', { 'class': 'right' }, [ - E('button', { - 'class': 'cbi-button', - 'click': ui.hideModal - }, 'Cancel'), + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Cancel'), E('button', { 'class': 'cbi-button cbi-button-negative', 'click': function() { ui.hideModal(); api.deleteCertificate(cert.id).then(function(res) { if (res.success) { - ui.addNotification(null, E('p', {}, 'Certificate deleted')); + ui.addNotification(null, E('p', {}, 'โœ… Certificate deleted')); window.location.reload(); } else { - ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); + ui.addNotification(null, E('p', {}, 'โŒ Failed: ' + (res.error || 'Unknown error')), 'error'); } }); } - }, 'Delete') + }, '๐Ÿ—‘๏ธ Delete') ]) ]); }, + getStyles: function() { + return ` + .cert-section { margin-bottom: 25px; padding: 15px; background: #f8f9fa; border-radius: 8px; } + @media (prefers-color-scheme: dark) { .cert-section { background: #1a1a2e; } } + + .cert-section h3 { margin: 0 0 15px 0; font-size: 1.1em; } + + .cert-form-row { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; } + .cert-form-row label { min-width: 120px; padding-top: 8px; font-weight: 500; } + .cert-form-row input[type="text"], .cert-form-row textarea { flex: 1; max-width: 400px; } + .cert-hint { font-size: 0.85em; color: #666; margin-left: 10px; padding-top: 8px; } + + .cert-mode-toggle { display: flex; gap: 15px; } + .cert-mode-option { display: flex; flex-direction: column; padding: 10px 15px; border: 2px solid #ddd; border-radius: 8px; cursor: pointer; } + .cert-mode-option:has(input:checked) { border-color: #0099cc; background: rgba(0,153,204,0.1); } + .cert-mode-option input { display: none; } + .cert-mode-label { font-weight: 600; margin-bottom: 4px; } + .cert-mode-desc { font-size: 0.8em; color: #666; } + .cert-mode-prod { color: #22c55e; } + .cert-mode-staging { color: #f59e0b; } + + .cert-progress { margin-top: 20px; padding: 15px; background: #fff; border: 2px solid #0099cc; border-radius: 8px; } + @media (prefers-color-scheme: dark) { .cert-progress { background: #16213e; } } + + .cert-progress-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; } + .cert-progress-icon { font-size: 1.5em; } + .cert-progress-domain { font-weight: 600; font-size: 1.1em; flex: 1; } + .cert-status { padding: 4px 10px; border-radius: 12px; font-size: 0.8em; font-weight: 500; } + .cert-status-prod { background: #22c55e; color: #fff; } + .cert-status-staging { background: #f59e0b; color: #000; } + + .cert-progress-phases { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; } + .cert-phase { padding: 6px 12px; background: #eee; border-radius: 16px; font-size: 0.85em; } + .cert-phase-done { background: #dcfce7; color: #166534; } + .cert-phase-active { background: #dbeafe; color: #1d4ed8; } + @media (prefers-color-scheme: dark) { + .cert-phase { background: #333; } + .cert-phase-done { background: #166534; color: #dcfce7; } + .cert-phase-active { background: #1d4ed8; color: #dbeafe; } + } + + .cert-progress-message { font-size: 0.9em; color: #666; padding: 8px; background: #f5f5f5; border-radius: 4px; } + @media (prefers-color-scheme: dark) { .cert-progress-message { background: #2a2a3e; color: #aaa; } } + + .cert-list { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; } + @media (prefers-color-scheme: dark) { .cert-list { border-color: #444; } } + + .cert-row { display: flex; align-items: center; padding: 12px; border-bottom: 1px solid #eee; gap: 15px; } + .cert-row:last-child { border-bottom: none; } + @media (prefers-color-scheme: dark) { .cert-row { border-bottom-color: #333; } } + + .cert-col-status { width: 30px; text-align: center; font-size: 1.1em; } + .cert-col-domain { flex: 2; min-width: 150px; } + .cert-col-domain strong { display: block; } + .cert-type-badge { font-size: 0.8em; color: #666; } + .cert-col-expiry { flex: 1; min-width: 120px; font-size: 0.9em; } + .cert-expiring { color: #ef4444; font-weight: 500; } + .cert-col-issuer { flex: 1; min-width: 100px; font-size: 0.85em; color: #666; } + .cert-col-action { width: 50px; } + + .cert-btn { border: none; background: transparent; cursor: pointer; font-size: 1.1em; padding: 6px 10px; border-radius: 4px; } + .cert-btn:hover { background: rgba(0,0,0,0.1); } + .cert-btn-delete:hover { background: rgba(239,68,68,0.2); } + + .cert-empty { color: #888; font-style: italic; padding: 20px; text-align: center; } + + .cert-active-tasks { background: #fef3c7; border: 2px solid #f59e0b; } + @media (prefers-color-scheme: dark) { .cert-active-tasks { background: #422006; border-color: #f59e0b; } } + .cert-task-list { display: flex; flex-direction: column; gap: 10px; } + .cert-task-item { display: flex; align-items: center; gap: 15px; padding: 10px; background: rgba(255,255,255,0.5); border-radius: 6px; } + .cert-task-domain { font-weight: 600; flex: 1; } + .cert-task-phase { font-size: 0.85em; color: #666; } + `; + }, + handleSaveApply: null, handleSave: null, handleReset: null diff --git a/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy b/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy index ec5e77f8..1d7e914b 100755 --- a/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy +++ b/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy @@ -765,7 +765,204 @@ _add_certificate() { json_close_object } -# Request certificate (ACME) +# Async certificate request task directory +CERT_TASK_DIR="/tmp/haproxy-cert-tasks" +mkdir -p "$CERT_TASK_DIR" 2>/dev/null + +# Start async certificate request (returns immediately with task_id) +method_start_cert_request() { + local domain staging + + read -r input + json_load "$input" + json_get_var domain domain + json_get_var staging staging + + if [ -z "$domain" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Domain is required" + json_dump + return + fi + + # Generate task ID + local task_id + task_id="cert_$(date +%s)_$$" + local task_file="$CERT_TASK_DIR/$task_id" + + # Validate domain format + if ! echo "$domain" | grep -qE '^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$'; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Invalid domain format" + json_dump + return + fi + + # Initialize task status + cat > "$task_file" </dev/null 2>&1; then + sed -i 's/"status": "[^"]*"/"status": "failed"/' "$task_file" + sed -i 's/"message": "[^"]*"/"message": "DNS lookup failed for domain"/' "$task_file" + sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file" + exit 1 + fi + + # Update status: requesting + sed -i 's/"phase": "[^"]*"/"phase": "requesting"/' "$task_file" + sed -i 's/"message": "[^"]*"/"message": "Requesting certificate from ACME..."/' "$task_file" + sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file" + + # Run certificate request + local acme_result acme_rc + if [ "${staging:-0}" = "1" ]; then + acme_result=$("$HAPROXYCTL" cert add "$domain" --staging 2>&1) + else + acme_result=$("$HAPROXYCTL" cert add "$domain" 2>&1) + fi + acme_rc=$? + + # Update status based on result + if [ $acme_rc -eq 0 ]; then + # Verify certificate was created + sed -i 's/"phase": "[^"]*"/"phase": "verifying"/' "$task_file" + sed -i 's/"message": "[^"]*"/"message": "Verifying certificate..."/' "$task_file" + sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file" + + sleep 1 + + # Check if cert file exists + local cert_file="/srv/haproxy/certs/${domain}.pem" + if [ -f "$cert_file" ]; then + local expiry issuer + expiry=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | sed 's/notAfter=//') + issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/.*O = //' | cut -d',' -f1) + + sed -i 's/"status": "[^"]*"/"status": "success"/' "$task_file" + sed -i 's/"phase": "[^"]*"/"phase": "complete"/' "$task_file" + sed -i "s/\"message\": \"[^\"]*\"/\"message\": \"Certificate issued by $issuer, expires $expiry\"/" "$task_file" + sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file" + else + sed -i 's/"status": "[^"]*"/"status": "failed"/' "$task_file" + sed -i "s/\"message\": \"[^\"]*\"/\"message\": \"Certificate file not found after request\"/" "$task_file" + sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file" + fi + else + sed -i 's/"status": "[^"]*"/"status": "failed"/' "$task_file" + # Escape special chars in error message + local safe_error + safe_error=$(echo "$acme_result" | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-200) + sed -i "s/\"message\": \"[^\"]*\"/\"message\": \"$safe_error\"/" "$task_file" + sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file" + fi + ) & + + # Return task ID immediately + json_init + json_add_boolean "success" 1 + json_add_string "task_id" "$task_id" + json_add_string "message" "Certificate request started" + json_dump +} + +# Get certificate task status +method_get_cert_task() { + local task_id + + read -r input + json_load "$input" + json_get_var task_id task_id + + if [ -z "$task_id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "task_id is required" + json_dump + return + fi + + local task_file="$CERT_TASK_DIR/$task_id" + if [ ! -f "$task_file" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Task not found" + json_dump + return + fi + + # Return task file contents + cat "$task_file" +} + +# List all certificate tasks +method_list_cert_tasks() { + json_init + json_add_array "tasks" + + for task_file in "$CERT_TASK_DIR"/cert_*; do + [ -f "$task_file" ] || continue + local task_id status domain phase + task_id=$(basename "$task_file") + status=$(jsonfilter -i "$task_file" -e '@.status' 2>/dev/null) + domain=$(jsonfilter -i "$task_file" -e '@.domain' 2>/dev/null) + phase=$(jsonfilter -i "$task_file" -e '@.phase' 2>/dev/null) + + json_add_object "" + json_add_string "task_id" "$task_id" + json_add_string "domain" "$domain" + json_add_string "status" "$status" + json_add_string "phase" "$phase" + json_close_object + done + + json_close_array + json_dump +} + +# Clean old certificate tasks (> 1 hour) +method_clean_cert_tasks() { + local cleaned=0 + local now + now=$(date +%s) + + for task_file in "$CERT_TASK_DIR"/cert_*; do + [ -f "$task_file" ] || continue + local started + started=$(jsonfilter -i "$task_file" -e '@.started' 2>/dev/null) + if [ -n "$started" ] && [ $((now - started)) -gt 3600 ]; then + rm -f "$task_file" + cleaned=$((cleaned + 1)) + fi + done + + json_init + json_add_boolean "success" 1 + json_add_int "cleaned" "$cleaned" + json_dump +} + +# Request certificate (ACME) - synchronous (kept for compatibility) method_request_certificate() { local domain @@ -1382,6 +1579,10 @@ case "$1" in "delete_server": { "id": "string", "inline": "boolean" }, "list_certificates": {}, "request_certificate": { "domain": "string" }, + "start_cert_request": { "domain": "string", "staging": "boolean" }, + "get_cert_task": { "task_id": "string" }, + "list_cert_tasks": {}, + "clean_cert_tasks": {}, "import_certificate": { "domain": "string", "cert": "string", "key": "string" }, "delete_certificate": { "id": "string" }, "list_acls": {}, @@ -1425,6 +1626,10 @@ EOF delete_server) method_delete_server ;; list_certificates) method_list_certificates ;; request_certificate) method_request_certificate ;; + start_cert_request) method_start_cert_request ;; + get_cert_task) method_get_cert_task ;; + list_cert_tasks) method_list_cert_tasks ;; + clean_cert_tasks) method_clean_cert_tasks ;; import_certificate) method_import_certificate ;; delete_certificate) method_delete_certificate ;; list_acls) method_list_acls ;; diff --git a/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json b/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json index d6ea37ad..a0c0fe42 100644 --- a/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json +++ b/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json @@ -12,6 +12,8 @@ "get_backend", "list_servers", "list_certificates", + "list_cert_tasks", + "get_cert_task", "list_acls", "list_redirects", "get_settings", @@ -34,6 +36,10 @@ "update_server", "delete_server", "request_certificate", + "start_cert_request", + "get_cert_task", + "list_cert_tasks", + "clean_cert_tasks", "import_certificate", "delete_certificate", "create_acl", diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js index bd282f0e..62155e63 100644 --- a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js @@ -5,87 +5,19 @@ 'require ui'; 'require service-registry/api as api'; -// Icon mapping -var icons = { - 'server': '๐Ÿ–ฅ๏ธ', 'music': '๐ŸŽต', 'shield': '๐Ÿ›ก๏ธ', 'chart': '๐Ÿ“Š', - 'settings': 'โš™๏ธ', 'git': '๐Ÿ“ฆ', 'blog': '๐Ÿ“', 'arrow': 'โžก๏ธ', - 'onion': '๐Ÿง…', 'lock': '๐Ÿ”’', 'globe': '๐ŸŒ', 'box': '๐Ÿ“ฆ', - 'app': '๐Ÿ“ฑ', 'admin': '๐Ÿ‘ค', 'stats': '๐Ÿ“ˆ', 'security': '๐Ÿ”', - 'feed': '๐Ÿ“ก', 'default': '๐Ÿ”—' +// Category icons +var catIcons = { + 'proxy': '๐ŸŒ', 'privacy': '๐Ÿง…', 'system': 'โš™๏ธ', 'app': '๐Ÿ“ฑ', + 'media': '๐ŸŽต', 'security': '๐Ÿ”', 'container': '๐Ÿ“ฆ', 'services': '๐Ÿ–ฅ๏ธ', + 'monitoring': '๐Ÿ“Š', 'other': '๐Ÿ”—' }; -function getIcon(name) { - return icons[name] || icons['default']; +// Generate QR code using QR Server API (free, reliable) +function generateQRCodeImg(data, size) { + var url = 'https://api.qrserver.com/v1/create-qr-code/?size=' + size + 'x' + size + '&data=' + encodeURIComponent(data); + return 'QR Code'; } -// Simple QR code generator -var QRCode = { - generateSVG: function(data, size) { - // Basic implementation - generates a simple visual representation - var matrix = this.generateMatrix(data); - var cellSize = size / matrix.length; - var svg = ''; - svg += ''; - for (var row = 0; row < matrix.length; row++) { - for (var col = 0; col < matrix[row].length; col++) { - if (matrix[row][col]) { - svg += ''; - } - } - } - svg += ''; - return svg; - }, - generateMatrix: function(data) { - var size = Math.max(21, Math.min(41, Math.ceil(data.length / 2) + 17)); - var matrix = []; - for (var i = 0; i < size; i++) { - matrix[i] = []; - for (var j = 0; j < size; j++) { - matrix[i][j] = 0; - } - } - // Add finder patterns - this.addFinderPattern(matrix, 0, 0); - this.addFinderPattern(matrix, size - 7, 0); - this.addFinderPattern(matrix, 0, size - 7); - // Timing - for (var i = 8; i < size - 8; i++) { - matrix[6][i] = matrix[i][6] = i % 2 === 0 ? 1 : 0; - } - // Data encoding (simplified) - var dataIndex = 0; - for (var col = size - 1; col > 0; col -= 2) { - if (col === 6) col--; - for (var row = 0; row < size; row++) { - for (var c = 0; c < 2; c++) { - var x = col - c; - if (matrix[row][x] === 0 && dataIndex < data.length * 8) { - var byteIndex = Math.floor(dataIndex / 8); - var bitIndex = dataIndex % 8; - var bit = byteIndex < data.length ? - (data.charCodeAt(byteIndex) >> (7 - bitIndex)) & 1 : 0; - matrix[row][x] = bit; - dataIndex++; - } - } - } - } - return matrix; - }, - addFinderPattern: function(matrix, row, col) { - for (var r = 0; r < 7; r++) { - for (var c = 0; c < 7; c++) { - if ((r === 0 || r === 6 || c === 0 || c === 6) || - (r >= 2 && r <= 4 && c >= 2 && c <= 4)) { - matrix[row + r][col + c] = 1; - } - } - } - } -}; - return view.extend({ title: _('Service Registry'), pollInterval: 30, @@ -98,232 +30,56 @@ return view.extend({ var self = this; var services = data.services || []; var providers = data.providers || {}; - var categories = data.categories || []; // Load CSS - var link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = L.resource('service-registry/registry.css'); - document.head.appendChild(link); + var style = document.createElement('style'); + style.textContent = this.getStyles(); + document.head.appendChild(style); - return E('div', { 'class': 'sr-dashboard' }, [ - this.renderHeader(), - this.renderStats(services, providers), - this.renderProviders(providers, data.haproxy, data.tor), - this.renderQuickPublish(categories), - this.renderServiceGrid(services, categories), + var published = services.filter(function(s) { return s.published; }); + var unpublished = services.filter(function(s) { return !s.published; }); + + return E('div', { 'class': 'sr-compact' }, [ + this.renderHeader(services, providers, data.haproxy, data.tor), + this.renderSection('๐Ÿ“ก Published Services', published, true), + this.renderSection('๐Ÿ” Discovered Services', unpublished, false), this.renderLandingLink(data.landing) ]); }, - renderHeader: function() { - return E('h2', { 'class': 'cbi-title' }, _('Service Registry')); - }, - - renderStats: function(services, providers) { + renderHeader: function(services, providers, haproxy, tor) { var published = services.filter(function(s) { return s.published; }).length; var running = services.filter(function(s) { return s.status === 'running'; }).length; var haproxyCount = providers.haproxy ? providers.haproxy.count : 0; var torCount = providers.tor ? providers.tor.count : 0; - return E('div', { 'class': 'sr-stats' }, [ - E('div', { 'class': 'sr-stat-card' }, [ - E('div', { 'class': 'sr-stat-value' }, String(published)), - E('div', { 'class': 'sr-stat-label' }, _('Published')) + var haproxyStatus = haproxy && haproxy.container_running ? '๐ŸŸข' : '๐Ÿ”ด'; + var torStatus = tor && tor.running ? '๐ŸŸข' : '๐Ÿ”ด'; + + return E('div', { 'class': 'sr-header' }, [ + E('div', { 'class': 'sr-title' }, [ + E('h2', {}, '๐Ÿ—‚๏ธ Service Registry'), + E('span', { 'class': 'sr-subtitle' }, + published + ' published ยท ' + running + ' running ยท ' + + haproxyCount + ' domains ยท ' + torCount + ' onion') ]), - E('div', { 'class': 'sr-stat-card' }, [ - E('div', { 'class': 'sr-stat-value' }, String(running)), - E('div', { 'class': 'sr-stat-label' }, _('Running')) - ]), - E('div', { 'class': 'sr-stat-card' }, [ - E('div', { 'class': 'sr-stat-value' }, String(haproxyCount)), - E('div', { 'class': 'sr-stat-label' }, _('Domains')) - ]), - E('div', { 'class': 'sr-stat-card' }, [ - E('div', { 'class': 'sr-stat-value' }, String(torCount)), - E('div', { 'class': 'sr-stat-label' }, _('Onion Sites')) + E('div', { 'class': 'sr-providers-bar' }, [ + E('span', { 'class': 'sr-provider-badge' }, haproxyStatus + ' HAProxy'), + E('span', { 'class': 'sr-provider-badge' }, torStatus + ' Tor'), + E('span', { 'class': 'sr-provider-badge' }, '๐Ÿ“Š ' + (providers.direct ? providers.direct.count : 0) + ' ports'), + E('span', { 'class': 'sr-provider-badge' }, '๐Ÿ“ฆ ' + (providers.lxc ? providers.lxc.count : 0) + ' LXC') ]) ]); }, - renderProviders: function(providers, haproxy, tor) { - return E('div', { 'class': 'sr-providers' }, [ - E('div', { 'class': 'sr-provider' }, [ - E('span', { 'class': 'sr-provider-dot ' + (haproxy && haproxy.container_running ? 'running' : 'stopped') }), - E('span', {}, _('HAProxy')) - ]), - E('div', { 'class': 'sr-provider' }, [ - E('span', { 'class': 'sr-provider-dot ' + (tor && tor.running ? 'running' : 'stopped') }), - E('span', {}, _('Tor')) - ]), - E('div', { 'class': 'sr-provider' }, [ - E('span', { 'class': 'sr-provider-dot running' }), - E('span', {}, _('Direct: ') + String(providers.direct ? providers.direct.count : 0)) - ]), - E('div', { 'class': 'sr-provider' }, [ - E('span', { 'class': 'sr-provider-dot running' }), - E('span', {}, _('LXC: ') + String(providers.lxc ? providers.lxc.count : 0)) - ]) - ]); - }, - - renderQuickPublish: function(categories) { - var self = this; - - var categoryOptions = [E('option', { 'value': 'services' }, _('Services'))]; - categories.forEach(function(cat) { - categoryOptions.push(E('option', { 'value': cat.id }, cat.name)); - }); - - return E('div', { 'class': 'sr-quick-publish' }, [ - E('h3', {}, _('Quick Publish')), - E('div', { 'class': 'sr-form' }, [ - E('div', { 'class': 'sr-form-group' }, [ - E('label', {}, _('Service Name')), - E('input', { 'type': 'text', 'id': 'pub-name', 'placeholder': 'e.g., Gitea' }) - ]), - E('div', { 'class': 'sr-form-group' }, [ - E('label', {}, _('Local Port')), - E('input', { 'type': 'number', 'id': 'pub-port', 'placeholder': '3000' }) - ]), - E('div', { 'class': 'sr-form-group' }, [ - E('label', {}, _('Domain (optional)')), - E('input', { 'type': 'text', 'id': 'pub-domain', 'placeholder': 'git.example.com' }) - ]), - E('div', { 'class': 'sr-form-group' }, [ - E('label', {}, _('Category')), - E('select', { 'id': 'pub-category' }, categoryOptions) - ]), - E('div', { 'class': 'sr-checkbox-group' }, [ - E('input', { 'type': 'checkbox', 'id': 'pub-tor' }), - E('label', { 'for': 'pub-tor' }, _('Enable Tor Hidden Service')) - ]), - E('button', { - 'class': 'cbi-button cbi-button-apply', - 'click': ui.createHandlerFn(this, 'handlePublish') - }, _('Publish')) - ]) - ]); - }, - - handlePublish: function() { - var self = this; - var name = document.getElementById('pub-name').value.trim(); - var port = parseInt(document.getElementById('pub-port').value); - var domain = document.getElementById('pub-domain').value.trim(); - var category = document.getElementById('pub-category').value; - var tor = document.getElementById('pub-tor').checked; - - if (!name || !port) { - ui.addNotification(null, E('p', _('Name and port are required')), 'error'); - return; - } - - ui.showModal(_('Publishing Service'), [ - E('p', { 'class': 'spinning' }, _('Creating service endpoints...')) - ]); - - return api.publishService(name, port, domain, tor, category, '').then(function(result) { - ui.hideModal(); - - if (result.success) { - self.showPublishedModal(result); - // Refresh view - return self.load().then(function(data) { - var container = document.querySelector('.sr-dashboard'); - if (container) { - dom.content(container, self.render(data).childNodes); - } - }); - } else { - ui.addNotification(null, E('p', _('Failed to publish: ') + (result.error || 'Unknown error')), 'error'); - } - }).catch(function(err) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + err.message), 'error'); - }); - }, - - showPublishedModal: function(result) { - var urls = result.urls || {}; - var content = [ - E('div', { 'class': 'sr-published-modal' }, [ - E('h3', {}, _('Service Published Successfully!')), - E('p', {}, result.name) - ]) - ]; - - var urlsDiv = E('div', { 'class': 'sr-urls' }); - - if (urls.local) { - urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [ - E('label', {}, _('Local')), - E('input', { 'readonly': true, 'value': urls.local }) - ])); - } - - if (urls.clearnet) { - urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [ - E('label', {}, _('Clearnet')), - E('input', { 'readonly': true, 'value': urls.clearnet }), - E('div', { 'class': 'sr-qr-code' }), - ])); - var qrDiv = urlsDiv.querySelector('.sr-qr-code:last-child'); - if (qrDiv) { - qrDiv.innerHTML = QRCode.generateSVG(urls.clearnet, 120); - } - } - - if (urls.onion) { - urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [ - E('label', {}, _('Onion')), - E('input', { 'readonly': true, 'value': urls.onion }), - E('div', { 'class': 'sr-qr-code' }) - ])); - var qrDiv = urlsDiv.querySelectorAll('.sr-qr-code'); - if (qrDiv.length > 0) { - qrDiv[qrDiv.length - 1].innerHTML = QRCode.generateSVG(urls.onion, 120); - } - } - - content[0].appendChild(urlsDiv); - - // Share buttons - var shareUrl = urls.clearnet || urls.onion || urls.local; - if (shareUrl) { - content[0].appendChild(E('div', { 'class': 'sr-share-buttons' }, [ - E('a', { - 'href': 'https://twitter.com/intent/tweet?url=' + encodeURIComponent(shareUrl), - 'target': '_blank', - 'title': 'Share on X' - }, 'X'), - E('a', { - 'href': 'https://t.me/share/url?url=' + encodeURIComponent(shareUrl), - 'target': '_blank', - 'title': 'Share on Telegram' - }, 'TG'), - E('a', { - 'href': 'https://wa.me/?text=' + encodeURIComponent(shareUrl), - 'target': '_blank', - 'title': 'Share on WhatsApp' - }, 'WA') - ])); - } - - content.push(E('div', { 'class': 'right' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close')) - ])); - - ui.showModal(_('Service Published'), content); - }, - - renderServiceGrid: function(services, categories) { + renderSection: function(title, services, isPublished) { var self = this; if (services.length === 0) { - return E('div', { 'class': 'sr-empty' }, [ - E('h3', {}, _('No Services Found')), - E('p', {}, _('Use the quick publish form above to add your first service')) + return E('div', { 'class': 'sr-section' }, [ + E('h3', { 'class': 'sr-section-title' }, title), + E('div', { 'class': 'sr-empty-msg' }, isPublished ? + 'No published services yet' : 'No discovered services') ]); } @@ -335,178 +91,296 @@ return view.extend({ grouped[cat].push(svc); }); - var sections = []; + var lists = []; Object.keys(grouped).sort().forEach(function(cat) { - sections.push(E('div', { 'class': 'cbi-section' }, [ - E('h3', {}, cat.charAt(0).toUpperCase() + cat.slice(1)), - E('div', { 'class': 'sr-grid' }, + var catIcon = catIcons[cat] || '๐Ÿ”—'; + lists.push(E('div', { 'class': 'sr-category' }, [ + E('div', { 'class': 'sr-cat-header' }, catIcon + ' ' + cat.charAt(0).toUpperCase() + cat.slice(1)), + E('div', { 'class': 'sr-list' }, grouped[cat].map(function(svc) { - return self.renderServiceCard(svc); + return self.renderServiceRow(svc, isPublished); }) ) ])); }); - return E('div', {}, sections); - }, - - renderServiceCard: function(service) { - var self = this; - var urls = service.urls || {}; - - var urlRows = []; - if (urls.local) { - urlRows.push(this.renderUrlRow('Local', urls.local)); - } - if (urls.clearnet) { - urlRows.push(this.renderUrlRow('Clearnet', urls.clearnet)); - } - if (urls.onion) { - urlRows.push(this.renderUrlRow('Onion', urls.onion)); - } - - // QR codes for published services - var qrContainer = null; - if (service.published && (urls.clearnet || urls.onion)) { - var qrBoxes = []; - if (urls.clearnet) { - var qrBox = E('div', { 'class': 'sr-qr-box' }, [ - E('div', { 'class': 'sr-qr-code' }), - E('div', { 'class': 'sr-qr-label' }, _('Clearnet')) - ]); - qrBox.querySelector('.sr-qr-code').innerHTML = QRCode.generateSVG(urls.clearnet, 80); - qrBoxes.push(qrBox); - } - if (urls.onion) { - var qrBox = E('div', { 'class': 'sr-qr-box' }, [ - E('div', { 'class': 'sr-qr-code' }), - E('div', { 'class': 'sr-qr-label' }, _('Onion')) - ]); - qrBox.querySelector('.sr-qr-code').innerHTML = QRCode.generateSVG(urls.onion, 80); - qrBoxes.push(qrBox); - } - qrContainer = E('div', { 'class': 'sr-qr-container' }, qrBoxes); - } - - // Action buttons - var actions = []; - if (service.published) { - actions.push(E('button', { - 'class': 'cbi-button cbi-button-remove', - 'click': ui.createHandlerFn(this, 'handleUnpublish', service.id) - }, _('Unpublish'))); - } else { - actions.push(E('button', { - 'class': 'cbi-button cbi-button-apply', - 'click': ui.createHandlerFn(this, 'handleQuickPublishExisting', service) - }, _('Publish'))); - } - - return E('div', { 'class': 'sr-card' }, [ - E('div', { 'class': 'sr-card-header' }, [ - E('div', { 'class': 'sr-card-icon' }, getIcon(service.icon)), - E('div', { 'class': 'sr-card-title' }, service.name || service.id), - E('span', { - 'class': 'sr-card-status sr-status-' + (service.status || 'stopped') - }, service.status || 'unknown') - ]), - E('div', { 'class': 'sr-urls' }, urlRows), - qrContainer, - E('div', { 'class': 'sr-card-actions' }, actions) + return E('div', { 'class': 'sr-section' }, [ + E('h3', { 'class': 'sr-section-title' }, title + ' (' + services.length + ')'), + E('div', { 'class': 'sr-categories' }, lists) ]); }, - renderUrlRow: function(label, url) { - return E('div', { 'class': 'sr-url-row' }, [ - E('span', { 'class': 'sr-url-label' }, label), - E('a', { - 'class': 'sr-url-link', - 'href': url, - 'target': '_blank' - }, url), - E('button', { - 'class': 'cbi-button sr-copy-btn', - 'click': function() { - navigator.clipboard.writeText(url).then(function() { - ui.addNotification(null, E('p', _('URL copied to clipboard')), 'info'); - }); - } - }, _('Copy')) + renderServiceRow: function(service, isPublished) { + var self = this; + var urls = service.urls || {}; + + // Status indicators + var healthIcon = service.status === 'running' ? '๐ŸŸข' : + service.status === 'stopped' ? '๐Ÿ”ด' : '๐ŸŸก'; + var publishIcon = service.published ? 'โœ…' : 'โฌœ'; + + // Build URL display + var urlDisplay = ''; + if (urls.clearnet) { + urlDisplay = urls.clearnet; + } else if (urls.onion) { + urlDisplay = urls.onion.substring(0, 25) + '...'; + } else if (urls.local) { + urlDisplay = urls.local; + } + + // Port display + var portDisplay = service.local_port ? ':' + service.local_port : ''; + if (service.haproxy && service.haproxy.backend_port) { + portDisplay = ':' + service.haproxy.backend_port; + } + + // SSL/Cert badge + var sslBadge = null; + if (service.haproxy) { + if (service.haproxy.acme) { + sslBadge = E('span', { 'class': 'sr-badge sr-badge-acme', 'title': 'ACME Certificate' }, '๐Ÿ”’'); + } else if (service.haproxy.ssl) { + sslBadge = E('span', { 'class': 'sr-badge sr-badge-ssl', 'title': 'SSL Enabled' }, '๐Ÿ”'); + } + } + + // Tor badge + var torBadge = null; + if (service.tor && service.tor.enabled) { + torBadge = E('span', { 'class': 'sr-badge sr-badge-tor', 'title': 'Tor Hidden Service' }, '๐Ÿง…'); + } + + // QR button for published services with URLs + var qrBtn = null; + if (service.published && (urls.clearnet || urls.onion)) { + qrBtn = E('button', { + 'class': 'sr-btn sr-btn-qr', + 'title': 'Show QR Code', + 'click': ui.createHandlerFn(this, 'handleShowQR', service) + }, '๐Ÿ“ฑ'); + } + + // Action button + var actionBtn; + if (isPublished) { + actionBtn = E('button', { + 'class': 'sr-btn sr-btn-unpublish', + 'title': 'Unpublish', + 'click': ui.createHandlerFn(this, 'handleUnpublish', service.id) + }, 'โœ–'); + } else { + actionBtn = E('button', { + 'class': 'sr-btn sr-btn-publish', + 'title': 'Quick Publish', + 'click': ui.createHandlerFn(this, 'handleQuickPublish', service) + }, '๐Ÿ“ค'); + } + + return E('div', { 'class': 'sr-row' }, [ + E('span', { 'class': 'sr-col-health', 'title': service.status || 'unknown' }, healthIcon), + E('span', { 'class': 'sr-col-publish' }, publishIcon), + E('span', { 'class': 'sr-col-name' }, [ + E('strong', {}, service.name || service.id), + E('span', { 'class': 'sr-port' }, portDisplay) + ]), + E('span', { 'class': 'sr-col-url' }, + urlDisplay ? E('a', { 'href': urlDisplay.startsWith('http') ? urlDisplay : 'http://' + urlDisplay, 'target': '_blank' }, urlDisplay) : '-' + ), + E('span', { 'class': 'sr-col-badges' }, [sslBadge, torBadge].filter(Boolean)), + E('span', { 'class': 'sr-col-qr' }, qrBtn), + E('span', { 'class': 'sr-col-action' }, actionBtn) + ]); + }, + + handleShowQR: function(service) { + var urls = service.urls || {}; + var qrBoxes = []; + + if (urls.clearnet) { + var qrDiv = E('div', { 'class': 'sr-qr-box' }); + qrDiv.innerHTML = '
' + generateQRCodeImg(urls.clearnet, 150) + '
' + + '
๐ŸŒ Clearnet
' + + '
' + urls.clearnet + '
'; + qrBoxes.push(qrDiv); + } + + if (urls.onion) { + var qrDiv = E('div', { 'class': 'sr-qr-box' }); + qrDiv.innerHTML = '
' + generateQRCodeImg(urls.onion, 150) + '
' + + '
๐Ÿง… Onion
' + + '
' + urls.onion + '
'; + qrBoxes.push(qrDiv); + } + + ui.showModal('๐Ÿ“ฑ ' + (service.name || service.id), [ + E('div', { 'class': 'sr-qr-modal' }, qrBoxes), + E('div', { 'class': 'right', 'style': 'margin-top: 15px;' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close')) + ]) ]); }, handleUnpublish: function(serviceId) { var self = this; + if (!confirm('Unpublish this service?')) return; - ui.showModal(_('Unpublish Service'), [ - E('p', {}, _('Are you sure you want to unpublish this service?')), - E('p', {}, _('This will remove HAProxy vhost and Tor hidden service if configured.')), - E('div', { 'class': 'right' }, [ + ui.showModal(_('Unpublishing'), [ + E('p', { 'class': 'spinning' }, _('Removing service...')) + ]); + + api.unpublishService(serviceId).then(function(result) { + ui.hideModal(); + if (result.success) { + ui.addNotification(null, E('p', _('Service unpublished')), 'info'); + location.reload(); + } else { + ui.addNotification(null, E('p', _('Failed to unpublish')), 'error'); + } + }); + }, + + handleQuickPublish: function(service) { + var self = this; + var name = service.name || service.id; + var port = service.local_port || (service.haproxy ? service.haproxy.backend_port : 0); + + ui.showModal(_('Quick Publish: ' + name), [ + E('div', { 'class': 'sr-publish-form' }, [ + E('div', { 'class': 'sr-form-row' }, [ + E('label', {}, 'Domain (optional):'), + E('input', { 'type': 'text', 'id': 'qp-domain', 'placeholder': 'example.com' }) + ]), + E('div', { 'class': 'sr-form-row' }, [ + E('label', {}, [ + E('input', { 'type': 'checkbox', 'id': 'qp-tor' }), + ' Enable Tor Hidden Service' + ]) + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 15px;' }, [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), E('button', { - 'class': 'cbi-button cbi-button-negative', + 'class': 'cbi-button cbi-button-apply', 'click': function() { + var domain = document.getElementById('qp-domain').value.trim(); + var tor = document.getElementById('qp-tor').checked; ui.hideModal(); - ui.showModal(_('Unpublishing'), [ - E('p', { 'class': 'spinning' }, _('Removing service...')) + ui.showModal(_('Publishing'), [ + E('p', { 'class': 'spinning' }, _('Creating endpoints...')) ]); - - api.unpublishService(serviceId).then(function(result) { + api.publishService(name, port, domain, tor, service.category || 'services', '').then(function(result) { ui.hideModal(); if (result.success) { - ui.addNotification(null, E('p', _('Service unpublished')), 'info'); - return self.load().then(function(data) { - var container = document.querySelector('.sr-dashboard'); - if (container) { - dom.content(container, self.render(data).childNodes); - } - }); + ui.addNotification(null, E('p', 'โœ… ' + name + ' published!'), 'info'); + location.reload(); } else { - ui.addNotification(null, E('p', _('Failed to unpublish')), 'error'); + ui.addNotification(null, E('p', 'โŒ Failed: ' + (result.error || '')), 'error'); } }); } - }, _('Unpublish')) + }, '๐Ÿ“ค Publish') ]) ]); }, - handleQuickPublishExisting: function(service) { - document.getElementById('pub-name').value = service.name || ''; - document.getElementById('pub-port').value = service.local_port || ''; - document.getElementById('pub-name').focus(); - }, - renderLandingLink: function(landing) { - var path = landing && landing.path ? landing.path : '/www/secubox-services.html'; var exists = landing && landing.exists; - - return E('div', { 'class': 'sr-landing-link' }, [ - E('span', {}, _('Landing Page:')), + return E('div', { 'class': 'sr-footer' }, [ + E('span', {}, '๐Ÿ“„ Landing Page: '), exists ? - E('a', { 'href': '/secubox-services.html', 'target': '_blank' }, path) : - E('span', {}, _('Not generated')), + E('a', { 'href': '/secubox-services.html', 'target': '_blank' }, '/secubox-services.html โ†—') : + E('span', { 'class': 'sr-muted' }, 'Not generated'), E('button', { - 'class': 'cbi-button', + 'class': 'sr-btn sr-btn-regen', 'click': ui.createHandlerFn(this, 'handleRegenLanding') - }, _('Regenerate')) + }, '๐Ÿ”„ Regenerate') ]); }, handleRegenLanding: function() { - var self = this; - ui.showModal(_('Generating'), [ E('p', { 'class': 'spinning' }, _('Regenerating landing page...')) ]); - api.generateLandingPage().then(function(result) { ui.hideModal(); if (result.success) { - ui.addNotification(null, E('p', _('Landing page regenerated')), 'info'); + ui.addNotification(null, E('p', 'โœ… Landing page regenerated'), 'info'); } else { - ui.addNotification(null, E('p', _('Failed: ') + (result.error || '')), 'error'); + ui.addNotification(null, E('p', 'โŒ Failed: ' + (result.error || '')), 'error'); } }); + }, + + getStyles: function() { + return ` + .sr-compact { font-family: system-ui, -apple-system, sans-serif; } + .sr-header { margin-bottom: 20px; padding: 15px; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border-radius: 8px; color: #fff; } + .sr-title h2 { margin: 0 0 5px 0; font-size: 1.4em; } + .sr-subtitle { font-size: 0.85em; opacity: 0.8; } + .sr-providers-bar { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; } + .sr-provider-badge { background: rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 12px; font-size: 0.8em; } + + .sr-section { margin-bottom: 25px; } + .sr-section-title { font-size: 1.1em; margin: 0 0 10px 0; padding-bottom: 8px; border-bottom: 2px solid #0ff; color: #0ff; } + .sr-empty-msg { color: #888; font-style: italic; padding: 15px; } + + .sr-category { margin-bottom: 15px; } + .sr-cat-header { font-weight: 600; font-size: 0.9em; padding: 6px 10px; background: #f5f5f5; border-radius: 4px; margin-bottom: 5px; } + @media (prefers-color-scheme: dark) { .sr-cat-header { background: #2a2a3e; } } + + .sr-list { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; } + @media (prefers-color-scheme: dark) { .sr-list { border-color: #444; } } + + .sr-row { display: flex; align-items: center; padding: 8px 12px; border-bottom: 1px solid #eee; gap: 10px; transition: background 0.15s; } + .sr-row:last-child { border-bottom: none; } + .sr-row:hover { background: rgba(0,255,255,0.05); } + @media (prefers-color-scheme: dark) { .sr-row { border-bottom-color: #333; } } + + .sr-col-health { width: 24px; text-align: center; font-size: 0.9em; } + .sr-col-publish { width: 24px; text-align: center; } + .sr-col-name { flex: 1; min-width: 120px; } + .sr-col-name strong { display: block; } + .sr-port { font-size: 0.8em; color: #888; } + .sr-col-url { flex: 2; min-width: 150px; font-size: 0.85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sr-col-url a { color: #0099cc; text-decoration: none; } + .sr-col-url a:hover { text-decoration: underline; } + .sr-col-badges { width: 50px; display: flex; gap: 4px; } + .sr-col-qr { width: 36px; } + .sr-col-action { width: 36px; } + + .sr-badge { font-size: 0.85em; } + + .sr-btn { border: none; background: transparent; cursor: pointer; font-size: 1em; padding: 4px 8px; border-radius: 4px; transition: all 0.15s; } + .sr-btn:hover { background: rgba(0,0,0,0.1); } + .sr-btn-publish { color: #22c55e; } + .sr-btn-publish:hover { background: rgba(34,197,94,0.15); } + .sr-btn-unpublish { color: #ef4444; } + .sr-btn-unpublish:hover { background: rgba(239,68,68,0.15); } + .sr-btn-qr { color: #0099cc; } + .sr-btn-qr:hover { background: rgba(0,153,204,0.15); } + .sr-btn-regen { margin-left: 10px; font-size: 0.85em; } + + .sr-qr-modal { display: flex; gap: 30px; justify-content: center; flex-wrap: wrap; padding: 20px 0; } + .sr-qr-box { text-align: center; } + .sr-qr-code { background: #fff; padding: 10px; border-radius: 8px; display: inline-block; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } + .sr-qr-code svg { display: block; } + .sr-qr-label { margin-top: 10px; font-weight: 600; font-size: 0.9em; } + .sr-qr-url { margin-top: 5px; font-size: 0.75em; color: #666; max-width: 180px; word-break: break-all; } + + .sr-footer { margin-top: 20px; padding: 12px 15px; background: #f8f8f8; border-radius: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + @media (prefers-color-scheme: dark) { .sr-footer { background: #1a1a2e; } } + .sr-muted { color: #888; } + + .sr-publish-form { min-width: 300px; } + .sr-form-row { margin-bottom: 12px; } + .sr-form-row label { display: block; margin-bottom: 5px; } + .sr-form-row input[type="text"] { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; } + + @media (max-width: 768px) { + .sr-row { flex-wrap: wrap; } + .sr-col-url { flex-basis: 100%; order: 10; margin-top: 5px; } + } + `; } }); diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/publish.js b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/publish.js index 83f0aec2..c20d3651 100644 --- a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/publish.js +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/publish.js @@ -5,6 +5,13 @@ 'require form'; 'require service-registry/api as api'; +// Category icons +var catIcons = { + 'proxy': '๐ŸŒ', 'privacy': '๐Ÿง…', 'system': 'โš™๏ธ', 'app': '๐Ÿ“ฑ', + 'media': '๐ŸŽต', 'security': '๐Ÿ”', 'container': '๐Ÿ“ฆ', 'services': '๐Ÿ–ฅ๏ธ', + 'monitoring': '๐Ÿ“Š', 'other': '๐Ÿ”—' +}; + return view.extend({ title: _('Publish Service'), @@ -17,21 +24,20 @@ return view.extend({ render: function(data) { var self = this; - var categories = data[0].categories || []; + var categories = (data[0] && data[0].categories) || []; var unpublished = data[1] || []; - // Load CSS - var link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = L.resource('service-registry/registry.css'); - document.head.appendChild(link); + // Inject styles + var style = document.createElement('style'); + style.textContent = this.getStyles(); + document.head.appendChild(style); var m, s, o; - m = new form.Map('service-registry', _('Publish New Service'), + m = new form.Map('service-registry', '๐Ÿ“ค ' + _('Publish New Service'), _('Create a new published service with HAProxy reverse proxy and/or Tor hidden service.')); - s = m.section(form.NamedSection, '_new', 'service', _('Service Details')); + s = m.section(form.NamedSection, '_new', 'service', '๐Ÿ“‹ ' + _('Service Details')); s.anonymous = true; s.addremove = false; @@ -53,10 +59,11 @@ return view.extend({ o.rmempty = false; o = s.option(form.ListValue, 'category', _('Category')); - o.value('services', _('Services')); + o.value('services', '๐Ÿ–ฅ๏ธ Services'); categories.forEach(function(cat) { if (cat.id !== 'services') { - o.value(cat.id, cat.name); + var icon = catIcons[cat.id] || '๐Ÿ”—'; + o.value(cat.id, icon + ' ' + cat.name); } }); o.default = 'services'; @@ -67,7 +74,7 @@ return view.extend({ o.optional = true; // HAProxy section - s = m.section(form.NamedSection, '_haproxy', 'haproxy', _('HAProxy (Clearnet)'), + s = m.section(form.NamedSection, '_haproxy', 'haproxy', '๐ŸŒ ' + _('HAProxy (Clearnet)'), _('Configure a public domain with automatic HTTPS certificate')); s.anonymous = true; @@ -85,7 +92,7 @@ return view.extend({ return true; }; - o = s.option(form.Flag, 'ssl', _('Enable SSL/TLS'), + o = s.option(form.Flag, 'ssl', _('๐Ÿ”’ Enable SSL/TLS'), _('Request ACME certificate automatically')); o.default = '1'; o.depends('enabled', '1'); @@ -96,7 +103,7 @@ return view.extend({ o.depends('ssl', '1'); // Tor section - s = m.section(form.NamedSection, '_tor', 'tor', _('Tor Hidden Service'), + s = m.section(form.NamedSection, '_tor', 'tor', '๐Ÿง… ' + _('Tor Hidden Service'), _('Create a .onion address for anonymous access')); s.anonymous = true; @@ -113,20 +120,20 @@ return view.extend({ // Add custom publish button var publishBtn = E('button', { 'class': 'cbi-button cbi-button-apply', - 'style': 'margin-top: 20px;', + 'style': 'margin-top: 20px; font-size: 1.1em; padding: 10px 25px;', 'click': ui.createHandlerFn(self, 'handlePublish', m) - }, _('Publish Service')); + }, '๐Ÿ“ค ' + _('Publish Service')); mapEl.appendChild(E('div', { 'class': 'cbi-page-actions' }, [publishBtn])); // Add discoverable services section if (unpublished.length > 0) { mapEl.appendChild(E('div', { 'class': 'cbi-section', 'style': 'margin-top: 30px;' }, [ - E('h3', {}, _('Discovered Services')), - E('p', {}, _('These services are running but not yet published:')), - E('div', { 'class': 'sr-grid' }, - unpublished.slice(0, 10).map(function(svc) { - return self.renderDiscoveredCard(svc); + E('h3', { 'class': 'pub-section-title' }, '๐Ÿ” ' + _('Discovered Services') + ' (' + unpublished.length + ')'), + E('p', { 'class': 'pub-hint' }, _('Click a service to pre-fill the form above')), + E('div', { 'class': 'pub-list' }, + unpublished.slice(0, 15).map(function(svc) { + return self.renderDiscoveredRow(svc); }) ) ])); @@ -136,32 +143,75 @@ return view.extend({ }); }, - renderDiscoveredCard: function(service) { + renderDiscoveredRow: function(service) { var self = this; + var catIcon = catIcons[service.category] || '๐Ÿ”—'; + var statusIcon = service.status === 'running' ? '๐ŸŸข' : service.status === 'stopped' ? '๐Ÿ”ด' : '๐ŸŸก'; + return E('div', { - 'class': 'sr-card', - 'style': 'cursor: pointer;', + 'class': 'pub-row', 'click': function() { self.prefillForm(service); } }, [ - E('div', { 'class': 'sr-card-header' }, [ - E('div', { 'class': 'sr-card-title' }, service.name || 'Port ' + service.local_port), - E('span', { 'class': 'sr-card-status sr-status-running' }, 'running') + E('span', { 'class': 'pub-col-status', 'title': service.status }, statusIcon), + E('span', { 'class': 'pub-col-icon' }, catIcon), + E('span', { 'class': 'pub-col-name' }, [ + E('strong', {}, service.name || 'Port ' + service.local_port), + service.local_port ? E('span', { 'class': 'pub-port' }, ':' + service.local_port) : null ]), - E('p', { 'style': 'font-size: 0.9em; color: #666;' }, - _('Port: ') + service.local_port + ' | ' + _('Category: ') + (service.category || 'other')) + E('span', { 'class': 'pub-col-cat' }, service.category || 'other'), + E('span', { 'class': 'pub-col-action' }, [ + E('button', { + 'class': 'pub-btn', + 'title': 'Use this service', + 'click': function(ev) { + ev.stopPropagation(); + self.prefillForm(service); + } + }, 'โžก๏ธ') + ]) ]); }, prefillForm: function(service) { - var nameInput = document.querySelector('input[id*="name"]'); - var portInput = document.querySelector('input[id*="local_port"]'); + // Use specific selectors matching the form section + var nameInput = document.querySelector('input[id*="_new"][id*="name"]'); + var portInput = document.querySelector('input[id*="_new"][id*="local_port"]'); + var categorySelect = document.querySelector('select[id*="_new"][id*="category"]'); + var iconInput = document.querySelector('input[id*="_new"][id*="icon"]'); - if (nameInput) nameInput.value = service.name || ''; - if (portInput) portInput.value = service.local_port || ''; + // Set values and trigger change events for LuCI bindings + if (nameInput) { + nameInput.value = service.name || ''; + nameInput.dispatchEvent(new Event('input', { bubbles: true })); + nameInput.dispatchEvent(new Event('change', { bubbles: true })); + } + if (portInput) { + portInput.value = service.local_port || ''; + portInput.dispatchEvent(new Event('input', { bubbles: true })); + portInput.dispatchEvent(new Event('change', { bubbles: true })); + } + if (categorySelect && service.category) { + for (var i = 0; i < categorySelect.options.length; i++) { + if (categorySelect.options[i].value === service.category) { + categorySelect.selectedIndex = i; + categorySelect.dispatchEvent(new Event('change', { bubbles: true })); + break; + } + } + } + if (iconInput && service.icon) { + iconInput.value = service.icon; + iconInput.dispatchEvent(new Event('input', { bubbles: true })); + } - nameInput && nameInput.focus(); + // Scroll to form and focus + var formSection = document.querySelector('.cbi-section'); + if (formSection) formSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setTimeout(function() { nameInput && nameInput.focus(); }, 300); + + ui.addNotification(null, E('p', 'โœ… ' + _('Form pre-filled with ') + (service.name || 'Port ' + service.local_port)), 'info'); }, handlePublish: function(map) { @@ -186,25 +236,26 @@ return view.extend({ // Validation if (!name) { - ui.addNotification(null, E('p', _('Service name is required')), 'error'); + ui.addNotification(null, E('p', 'โŒ ' + _('Service name is required')), 'error'); return; } if (!port || port < 1 || port > 65535) { - ui.addNotification(null, E('p', _('Valid port number is required')), 'error'); + ui.addNotification(null, E('p', 'โŒ ' + _('Valid port number is required')), 'error'); return; } if (haproxyEnabled && !domain) { - ui.addNotification(null, E('p', _('Domain is required when HAProxy is enabled')), 'error'); + ui.addNotification(null, E('p', 'โŒ ' + _('Domain is required when HAProxy is enabled')), 'error'); return; } - ui.showModal(_('Publishing Service'), [ + var steps = []; + if (haproxyEnabled) steps.push('๐ŸŒ Creating HAProxy vhost for ' + domain); + if (haproxyEnabled) steps.push('๐Ÿ”’ Requesting SSL certificate...'); + if (torEnabled) steps.push('๐Ÿง… Creating Tor hidden service...'); + + ui.showModal('๐Ÿ“ค ' + _('Publishing Service'), [ E('p', { 'class': 'spinning' }, _('Creating service endpoints...')), - E('ul', {}, [ - haproxyEnabled ? E('li', {}, _('Creating HAProxy vhost for ') + domain) : null, - haproxyEnabled ? E('li', {}, _('Requesting SSL certificate...')) : null, - torEnabled ? E('li', {}, _('Creating Tor hidden service...')) : null - ].filter(Boolean)) + E('ul', { 'style': 'margin-top: 15px;' }, steps.map(function(s) { return E('li', {}, s); })) ]); return api.publishService( @@ -220,11 +271,11 @@ return view.extend({ if (result.success) { self.showSuccessModal(result); } else { - ui.addNotification(null, E('p', _('Failed to publish: ') + (result.error || 'Unknown error')), 'error'); + ui.addNotification(null, E('p', 'โŒ ' + _('Failed to publish: ') + (result.error || 'Unknown error')), 'error'); } }).catch(function(err) { ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + err.message), 'error'); + ui.addNotification(null, E('p', 'โŒ ' + _('Error: ') + err.message), 'error'); }); }, @@ -233,51 +284,94 @@ return view.extend({ var content = [ E('div', { 'style': 'text-align: center; padding: 20px;' }, [ - E('h3', { 'style': 'color: #22c55e;' }, _('Service Published!')), - E('p', {}, result.name) + E('div', { 'style': 'font-size: 3em; margin-bottom: 10px;' }, 'โœ…'), + E('h3', { 'style': 'color: #22c55e; margin: 0;' }, _('Service Published!')), + E('p', { 'style': 'font-size: 1.2em; margin-top: 10px;' }, result.name) ]) ]; - var urlsDiv = E('div', { 'style': 'margin: 20px 0;' }); + var urlsDiv = E('div', { 'class': 'pub-urls' }); if (urls.local) { - urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [ - E('strong', {}, _('Local: ')), + urlsDiv.appendChild(E('div', { 'class': 'pub-url-row' }, [ + E('span', { 'class': 'pub-url-icon' }, '๐Ÿ '), + E('span', { 'class': 'pub-url-label' }, _('Local')), E('code', {}, urls.local) ])); } if (urls.clearnet) { - urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [ - E('strong', {}, _('Clearnet: ')), - E('a', { 'href': urls.clearnet, 'target': '_blank' }, urls.clearnet) + urlsDiv.appendChild(E('div', { 'class': 'pub-url-row' }, [ + E('span', { 'class': 'pub-url-icon' }, '๐ŸŒ'), + E('span', { 'class': 'pub-url-label' }, _('Clearnet')), + E('a', { 'href': urls.clearnet, 'target': '_blank' }, urls.clearnet + ' โ†—') ])); } if (urls.onion) { - urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [ - E('strong', {}, _('Onion: ')), - E('code', { 'style': 'word-break: break-all;' }, urls.onion) + urlsDiv.appendChild(E('div', { 'class': 'pub-url-row' }, [ + E('span', { 'class': 'pub-url-icon' }, '๐Ÿง…'), + E('span', { 'class': 'pub-url-label' }, _('Onion')), + E('code', { 'style': 'font-size: 0.8em; word-break: break-all;' }, urls.onion) ])); } content.push(urlsDiv); - content.push(E('div', { 'class': 'right' }, [ + content.push(E('div', { 'class': 'right', 'style': 'margin-top: 20px;' }, [ E('button', { 'class': 'cbi-button', 'click': function() { ui.hideModal(); window.location.href = L.url('admin/services/service-registry/overview'); } - }, _('Go to Overview')), + }, '๐Ÿ“‹ ' + _('Go to Overview')), E('button', { 'class': 'cbi-button cbi-button-apply', + 'style': 'margin-left: 10px;', 'click': function() { ui.hideModal(); window.location.reload(); } - }, _('Publish Another')) + }, 'โž• ' + _('Publish Another')) ])); - ui.showModal(_('Success'), content); + ui.showModal('โœ… ' + _('Success'), content); + }, + + getStyles: function() { + return ` + .pub-section-title { font-size: 1.1em; margin: 0 0 10px 0; padding-bottom: 8px; border-bottom: 2px solid #0ff; color: #0ff; } + .pub-hint { color: #888; font-size: 0.9em; margin-bottom: 15px; } + + .pub-list { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; max-height: 400px; overflow-y: auto; } + @media (prefers-color-scheme: dark) { .pub-list { border-color: #444; } } + + .pub-row { display: flex; align-items: center; padding: 10px 12px; border-bottom: 1px solid #eee; gap: 10px; cursor: pointer; transition: background 0.15s; } + .pub-row:last-child { border-bottom: none; } + .pub-row:hover { background: rgba(0,255,255,0.08); } + @media (prefers-color-scheme: dark) { .pub-row { border-bottom-color: #333; } } + + .pub-col-status { width: 24px; text-align: center; } + .pub-col-icon { width: 24px; text-align: center; } + .pub-col-name { flex: 1; min-width: 120px; } + .pub-col-name strong { display: inline; } + .pub-port { font-size: 0.85em; color: #888; margin-left: 4px; } + .pub-col-cat { width: 80px; font-size: 0.85em; color: #666; } + .pub-col-action { width: 36px; } + + .pub-btn { border: none; background: transparent; cursor: pointer; font-size: 1.1em; padding: 4px 8px; border-radius: 4px; transition: all 0.15s; } + .pub-btn:hover { background: rgba(0,153,204,0.15); } + + .pub-urls { margin: 20px 0; padding: 15px; background: #f8f8f8; border-radius: 8px; } + @media (prefers-color-scheme: dark) { .pub-urls { background: #1a1a2e; } } + + .pub-url-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #eee; } + .pub-url-row:last-child { border-bottom: none; } + @media (prefers-color-scheme: dark) { .pub-url-row { border-bottom-color: #333; } } + + .pub-url-icon { font-size: 1.2em; } + .pub-url-label { font-weight: 600; min-width: 70px; } + .pub-url-row a { color: #0099cc; text-decoration: none; } + .pub-url-row a:hover { text-decoration: underline; } + `; } }); diff --git a/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-landing-gen b/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-landing-gen index f12d5f2b..c5ce4843 100644 --- a/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-landing-gen +++ b/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-landing-gen @@ -203,8 +203,9 @@ cat > "$OUTPUT_PATH" <<'HTMLHEAD' border-radius: 8px; display: inline-block; } - .qr-code svg { + .qr-code img { display: block; + border-radius: 4px; } .qr-label { font-size: 0.7em; @@ -264,77 +265,11 @@ cat > "$OUTPUT_PATH" <<'HTMLHEAD'