feat(haproxy,service-registry): Add async cert workflow and fix QR codes
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 <noreply@anthropic.com>
This commit is contained in:
parent
8d08ccd4a4
commit
bc5bd8d8ce
@ -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
|
||||
|
||||
@ -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" <<EOF
|
||||
{
|
||||
"task_id": "$task_id",
|
||||
"domain": "$domain",
|
||||
"staging": ${staging:-0},
|
||||
"status": "pending",
|
||||
"phase": "starting",
|
||||
"message": "Initializing certificate request...",
|
||||
"started": $(date +%s),
|
||||
"updated": $(date +%s)
|
||||
}
|
||||
EOF
|
||||
|
||||
# Start background process
|
||||
(
|
||||
# Update status: validating
|
||||
sed -i 's/"phase": "[^"]*"/"phase": "validating"/' "$task_file"
|
||||
sed -i 's/"message": "[^"]*"/"message": "Validating domain DNS..."/' "$task_file"
|
||||
sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file"
|
||||
|
||||
# Check DNS resolution
|
||||
if ! nslookup "$domain" >/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 ;;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 '<img src="' + url + '" alt="QR Code" style="display:block;" />';
|
||||
}
|
||||
|
||||
// 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 xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size + '">';
|
||||
svg += '<rect width="100%" height="100%" fill="white"/>';
|
||||
for (var row = 0; row < matrix.length; row++) {
|
||||
for (var col = 0; col < matrix[row].length; col++) {
|
||||
if (matrix[row][col]) {
|
||||
svg += '<rect x="' + (col * cellSize) + '" y="' + (row * cellSize) +
|
||||
'" width="' + cellSize + '" height="' + cellSize + '" fill="black"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = '<div class="sr-qr-code">' + generateQRCodeImg(urls.clearnet, 150) + '</div>' +
|
||||
'<div class="sr-qr-label">🌐 Clearnet</div>' +
|
||||
'<div class="sr-qr-url">' + urls.clearnet + '</div>';
|
||||
qrBoxes.push(qrDiv);
|
||||
}
|
||||
|
||||
if (urls.onion) {
|
||||
var qrDiv = E('div', { 'class': 'sr-qr-box' });
|
||||
qrDiv.innerHTML = '<div class="sr-qr-code">' + generateQRCodeImg(urls.onion, 150) + '</div>' +
|
||||
'<div class="sr-qr-label">🧅 Onion</div>' +
|
||||
'<div class="sr-qr-url">' + urls.onion + '</div>';
|
||||
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; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
@ -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; }
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
@ -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'
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// QR Code generator (minimal SVG implementation)
|
||||
var QRCode = (function() {
|
||||
function generateMatrix(data) {
|
||||
// Simple QR matrix generator - uses a basic encoding
|
||||
// For production, use a proper library
|
||||
var size = Math.max(21, Math.ceil(data.length / 2) + 17);
|
||||
size = Math.min(size, 41);
|
||||
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
|
||||
addFinderPattern(matrix, 0, 0);
|
||||
addFinderPattern(matrix, size - 7, 0);
|
||||
addFinderPattern(matrix, 0, size - 7);
|
||||
// Add timing patterns
|
||||
for (var i = 8; i < size - 8; i++) {
|
||||
matrix[6][i] = matrix[i][6] = i % 2 === 0 ? 1 : 0;
|
||||
}
|
||||
// Encode data (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;
|
||||
}
|
||||
function addFinderPattern(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 {
|
||||
generateSVG: function(data, size) {
|
||||
var matrix = generateMatrix(data);
|
||||
var cellSize = size / matrix.length;
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size + '">';
|
||||
svg += '<rect width="100%" height="100%" fill="white"/>';
|
||||
for (var row = 0; row < matrix.length; row++) {
|
||||
for (var col = 0; col < matrix[row].length; col++) {
|
||||
if (matrix[row][col]) {
|
||||
svg += '<rect x="' + (col * cellSize) + '" y="' + (row * cellSize) +
|
||||
'" width="' + cellSize + '" height="' + cellSize + '" fill="black"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
};
|
||||
})();
|
||||
// QR Code generator 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 '<img src="' + url + '" alt="QR Code" width="' + size + '" height="' + size + '" style="display:block;" />';
|
||||
}
|
||||
|
||||
// Service data
|
||||
var servicesData = SERVICES_JSON_PLACEHOLDER;
|
||||
@ -445,7 +380,7 @@ cat > "$OUTPUT_PATH" <<'HTMLHEAD'
|
||||
html += '<div class="qr-container">';
|
||||
qrUrls.forEach(function(qr) {
|
||||
html += '<div class="qr-box">';
|
||||
html += '<div class="qr-code">' + QRCode.generateSVG(qr.url, 100) + '</div>';
|
||||
html += '<div class="qr-code">' + generateQRCodeImg(qr.url, 120) + '</div>';
|
||||
html += '<div class="qr-label">' + qr.label + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user