feat(service-registry): Add dynamic health checks and URL readiness wizard
- Add health check RPCD methods: - check_service_health: Check DNS, cert, firewall for single domain - check_all_health: Batch check all published services - Add URL Readiness Checker wizard card to dashboard: - Check if domain DNS resolves correctly - Verify firewall ports 80/443 are open - Check SSL certificate status - Show actionable recommendations - Display inline health status badges on service rows: - DNS resolution status (ok/failed) - Certificate expiry (ok/warning/critical/expired) - Add health summary bar showing overall system status - Add per-service health check button Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b762bffa44
commit
ab8e0c44bc
@ -87,6 +87,19 @@ var callSaveLandingConfig = rpc.declare({
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callCheckServiceHealth = rpc.declare({
|
||||
object: 'luci.service-registry',
|
||||
method: 'check_service_health',
|
||||
params: ['service_id', 'domain'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callCheckAllHealth = rpc.declare({
|
||||
object: 'luci.service-registry',
|
||||
method: 'check_all_health',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
// HAProxy status for provider info
|
||||
var callHAProxyStatus = rpc.declare({
|
||||
object: 'luci.haproxy',
|
||||
@ -220,5 +233,37 @@ return baseclass.extend({
|
||||
// Full publish with HAProxy + Tor
|
||||
fullPublish: function(name, port, domain) {
|
||||
return this.publishService(name, port, domain, true, 'services', '');
|
||||
},
|
||||
|
||||
// Check health of a single service
|
||||
checkServiceHealth: function(serviceId, domain) {
|
||||
return callCheckServiceHealth(serviceId || '', domain || '');
|
||||
},
|
||||
|
||||
// Check health of all published services
|
||||
checkAllHealth: function() {
|
||||
return callCheckAllHealth();
|
||||
},
|
||||
|
||||
// Get dashboard data with health status
|
||||
getDashboardDataWithHealth: function() {
|
||||
return Promise.all([
|
||||
callListServices().catch(function(e) { console.error('list_services failed:', e); return { services: [], providers: {} }; }),
|
||||
callListCategories().catch(function(e) { console.error('list_categories failed:', e); return { categories: [] }; }),
|
||||
callGetLandingConfig().catch(function(e) { console.error('get_landing_config failed:', e); return {}; }),
|
||||
callHAProxyStatus().catch(function() { return { enabled: false }; }),
|
||||
callTorStatus().catch(function() { return { enabled: false }; }),
|
||||
callCheckAllHealth().catch(function(e) { console.error('check_all_health failed:', e); return { health: {} }; })
|
||||
]).then(function(results) {
|
||||
return {
|
||||
services: results[0].services || [],
|
||||
providers: results[0].providers || {},
|
||||
categories: results[1].categories || [],
|
||||
landing: results[2],
|
||||
haproxy: results[3],
|
||||
tor: results[4],
|
||||
health: results[5].health || {}
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -12,6 +12,13 @@ var catIcons = {
|
||||
'monitoring': '📊', 'other': '🔗'
|
||||
};
|
||||
|
||||
// Health status icons
|
||||
var healthIcons = {
|
||||
'dns': { 'ok': '🌐', 'failed': '❌', 'none': '⚪' },
|
||||
'cert': { 'ok': '🔒', 'warning': '⚠️', 'critical': '🔴', 'expired': '💀', 'missing': '⚪', 'none': '⚪' },
|
||||
'firewall': { 'ok': '✅', 'partial': '⚠️', 'closed': '🚫' }
|
||||
};
|
||||
|
||||
// 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);
|
||||
@ -21,9 +28,10 @@ function generateQRCodeImg(data, size) {
|
||||
return view.extend({
|
||||
title: _('Service Registry'),
|
||||
pollInterval: 30,
|
||||
healthData: null,
|
||||
|
||||
load: function() {
|
||||
return api.getDashboardData();
|
||||
return api.getDashboardDataWithHealth();
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
@ -31,6 +39,9 @@ return view.extend({
|
||||
var services = data.services || [];
|
||||
var providers = data.providers || {};
|
||||
|
||||
// Store health data for service lookups
|
||||
this.healthData = data.health || {};
|
||||
|
||||
// Load CSS
|
||||
var style = document.createElement('style');
|
||||
style.textContent = this.getStyles();
|
||||
@ -41,12 +52,177 @@ return view.extend({
|
||||
|
||||
return E('div', { 'class': 'sr-compact' }, [
|
||||
this.renderHeader(services, providers, data.haproxy, data.tor),
|
||||
this.renderHealthSummary(data.health),
|
||||
this.renderUrlChecker(),
|
||||
this.renderSection('📡 Published Services', published, true),
|
||||
this.renderSection('🔍 Discovered Services', unpublished, false),
|
||||
this.renderLandingLink(data.landing)
|
||||
]);
|
||||
},
|
||||
|
||||
renderHealthSummary: function(health) {
|
||||
if (!health || !health.firewall) return E('div');
|
||||
|
||||
var firewallStatus = health.firewall.status || 'unknown';
|
||||
var firewallIcon = healthIcons.firewall[firewallStatus] || '❓';
|
||||
var haproxyStatus = health.haproxy && health.haproxy.status === 'running' ? '🟢' : '🔴';
|
||||
var torStatus = health.tor && health.tor.status === 'running' ? '🟢' : '🔴';
|
||||
|
||||
// Count service health
|
||||
var services = health.services || [];
|
||||
var dnsOk = services.filter(function(s) { return s.dns_status === 'ok'; }).length;
|
||||
var certOk = services.filter(function(s) { return s.cert_status === 'ok'; }).length;
|
||||
var certWarn = services.filter(function(s) { return s.cert_status === 'warning' || s.cert_status === 'critical'; }).length;
|
||||
|
||||
return E('div', { 'class': 'sr-health-bar' }, [
|
||||
E('span', { 'class': 'sr-health-item', 'title': 'Firewall ports 80/443' },
|
||||
firewallIcon + ' Firewall: ' + firewallStatus),
|
||||
E('span', { 'class': 'sr-health-item', 'title': 'HAProxy container' },
|
||||
haproxyStatus + ' HAProxy'),
|
||||
E('span', { 'class': 'sr-health-item', 'title': 'Tor daemon' },
|
||||
torStatus + ' Tor'),
|
||||
services.length > 0 ? E('span', { 'class': 'sr-health-item' },
|
||||
'🌐 DNS: ' + dnsOk + '/' + services.length) : null,
|
||||
services.length > 0 ? E('span', { 'class': 'sr-health-item' },
|
||||
'🔒 Certs: ' + certOk + '/' + services.length +
|
||||
(certWarn > 0 ? ' (⚠️ ' + certWarn + ')' : '')) : null
|
||||
].filter(Boolean));
|
||||
},
|
||||
|
||||
renderUrlChecker: function() {
|
||||
var self = this;
|
||||
return E('div', { 'class': 'sr-wizard-card' }, [
|
||||
E('div', { 'class': 'sr-wizard-header' }, [
|
||||
E('span', { 'class': 'sr-wizard-icon' }, '🔍'),
|
||||
E('span', { 'class': 'sr-wizard-title' }, 'URL Readiness Checker'),
|
||||
E('span', { 'class': 'sr-wizard-desc' }, 'Check if a domain is ready to be hosted')
|
||||
]),
|
||||
E('div', { 'class': 'sr-wizard-form' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'url-check-domain',
|
||||
'placeholder': 'Enter domain (e.g., example.com)',
|
||||
'class': 'sr-wizard-input'
|
||||
}),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, 'handleUrlCheck')
|
||||
}, '🔍 Check')
|
||||
]),
|
||||
E('div', { 'id': 'url-check-results', 'class': 'sr-check-results' })
|
||||
]);
|
||||
},
|
||||
|
||||
handleUrlCheck: function() {
|
||||
var self = this;
|
||||
var domain = document.getElementById('url-check-domain').value.trim();
|
||||
var resultsDiv = document.getElementById('url-check-results');
|
||||
|
||||
if (!domain) {
|
||||
resultsDiv.innerHTML = '<div class="sr-check-error">Please enter a domain</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean domain (remove protocol if present)
|
||||
domain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
||||
|
||||
resultsDiv.innerHTML = '<div class="sr-check-loading">🔄 Checking ' + domain + '...</div>';
|
||||
|
||||
api.checkServiceHealth('', domain).then(function(result) {
|
||||
if (!result.success) {
|
||||
resultsDiv.innerHTML = '<div class="sr-check-error">❌ Check failed: ' + (result.error || 'Unknown error') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<div class="sr-check-grid">';
|
||||
|
||||
// DNS Status
|
||||
var dnsStatus = result.dns || {};
|
||||
var dnsIcon = healthIcons.dns[dnsStatus.status] || '❓';
|
||||
var dnsClass = dnsStatus.status === 'ok' ? 'sr-check-ok' : 'sr-check-fail';
|
||||
html += '<div class="sr-check-item ' + dnsClass + '">';
|
||||
html += '<span class="sr-check-icon">' + dnsIcon + '</span>';
|
||||
html += '<span class="sr-check-label">DNS Resolution</span>';
|
||||
if (dnsStatus.status === 'ok') {
|
||||
html += '<span class="sr-check-value">✅ Resolves to ' + dnsStatus.resolved_ip + '</span>';
|
||||
} else {
|
||||
html += '<span class="sr-check-value">❌ DNS not configured or not resolving</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Firewall Status
|
||||
var fwStatus = result.firewall || {};
|
||||
var fwIcon = healthIcons.firewall[fwStatus.status] || '❓';
|
||||
var fwClass = fwStatus.status === 'ok' ? 'sr-check-ok' : (fwStatus.status === 'partial' ? 'sr-check-warn' : 'sr-check-fail');
|
||||
html += '<div class="sr-check-item ' + fwClass + '">';
|
||||
html += '<span class="sr-check-icon">' + fwIcon + '</span>';
|
||||
html += '<span class="sr-check-label">Firewall Ports</span>';
|
||||
var ports = [];
|
||||
if (fwStatus.http_open) ports.push('80');
|
||||
if (fwStatus.https_open) ports.push('443');
|
||||
html += '<span class="sr-check-value">' + (ports.length ? 'Open: ' + ports.join(', ') : '❌ Ports 80/443 not open') + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Certificate Status
|
||||
var certStatus = result.certificate || {};
|
||||
var certIcon = healthIcons.cert[certStatus.status] || '❓';
|
||||
var certClass = certStatus.status === 'ok' ? 'sr-check-ok' : (certStatus.status === 'warning' ? 'sr-check-warn' : 'sr-check-fail');
|
||||
html += '<div class="sr-check-item ' + certClass + '">';
|
||||
html += '<span class="sr-check-icon">' + certIcon + '</span>';
|
||||
html += '<span class="sr-check-label">SSL Certificate</span>';
|
||||
if (certStatus.status === 'ok' || certStatus.status === 'warning') {
|
||||
html += '<span class="sr-check-value">' + certStatus.days_left + ' days remaining</span>';
|
||||
} else if (certStatus.status === 'expired') {
|
||||
html += '<span class="sr-check-value">❌ Certificate expired</span>';
|
||||
} else if (certStatus.status === 'missing') {
|
||||
html += '<span class="sr-check-value">⚪ No certificate (request via HAProxy)</span>';
|
||||
} else {
|
||||
html += '<span class="sr-check-value">⚪ Not applicable</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// HAProxy Status
|
||||
var haStatus = result.haproxy || {};
|
||||
var haIcon = haStatus.status === 'running' ? '🟢' : '🔴';
|
||||
var haClass = haStatus.status === 'running' ? 'sr-check-ok' : 'sr-check-fail';
|
||||
html += '<div class="sr-check-item ' + haClass + '">';
|
||||
html += '<span class="sr-check-icon">' + haIcon + '</span>';
|
||||
html += '<span class="sr-check-label">HAProxy</span>';
|
||||
html += '<span class="sr-check-value">' + (haStatus.status === 'running' ? '✅ Running' : '❌ Not running') + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Summary and recommendation
|
||||
var allOk = dnsStatus.status === 'ok' && fwStatus.status === 'ok' && haStatus.status === 'running';
|
||||
var needsCert = certStatus.status === 'missing';
|
||||
|
||||
html += '<div class="sr-check-summary">';
|
||||
if (allOk && !needsCert) {
|
||||
html += '<div class="sr-check-ready">✅ ' + domain + ' is ready and serving!</div>';
|
||||
} else if (allOk && needsCert) {
|
||||
html += '<div class="sr-check-almost">⚠️ ' + domain + ' is ready - just need SSL certificate</div>';
|
||||
html += '<a href="/cgi-bin/luci/admin/services/haproxy/certificates" class="sr-check-action">📜 Request Certificate</a>';
|
||||
} else {
|
||||
html += '<div class="sr-check-notready">❌ ' + domain + ' needs configuration</div>';
|
||||
if (dnsStatus.status !== 'ok') {
|
||||
html += '<div class="sr-check-tip">💡 Point DNS A record to your public IP</div>';
|
||||
}
|
||||
if (fwStatus.status !== 'ok') {
|
||||
html += '<div class="sr-check-tip">💡 Open ports 80 and 443 in firewall</div>';
|
||||
}
|
||||
if (haStatus.status !== 'running') {
|
||||
html += '<div class="sr-check-tip">💡 Start HAProxy container</div>';
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
resultsDiv.innerHTML = html;
|
||||
}).catch(function(err) {
|
||||
resultsDiv.innerHTML = '<div class="sr-check-error">❌ Error: ' + err.message + '</div>';
|
||||
});
|
||||
},
|
||||
|
||||
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;
|
||||
@ -135,20 +311,46 @@ return view.extend({
|
||||
portDisplay = ':' + service.haproxy.backend_port;
|
||||
}
|
||||
|
||||
// SSL/Cert badge
|
||||
var sslBadge = null;
|
||||
if (service.haproxy) {
|
||||
// Get health status for this domain (if available)
|
||||
var healthBadges = [];
|
||||
var domain = service.haproxy && service.haproxy.domain;
|
||||
if (domain && this.healthData && this.healthData.services) {
|
||||
var svcHealth = this.healthData.services.find(function(h) {
|
||||
return h.domain === domain;
|
||||
});
|
||||
if (svcHealth) {
|
||||
// DNS badge
|
||||
var dnsIcon = healthIcons.dns[svcHealth.dns_status] || '❓';
|
||||
var dnsTitle = svcHealth.dns_status === 'ok' ?
|
||||
'DNS OK: ' + svcHealth.dns_ip : 'DNS: ' + svcHealth.dns_status;
|
||||
healthBadges.push(E('span', {
|
||||
'class': 'sr-badge sr-badge-dns sr-badge-' + svcHealth.dns_status,
|
||||
'title': dnsTitle
|
||||
}, dnsIcon));
|
||||
|
||||
// Cert badge
|
||||
var certIcon = healthIcons.cert[svcHealth.cert_status] || '❓';
|
||||
var certTitle = svcHealth.cert_status === 'ok' || svcHealth.cert_status === 'warning' ?
|
||||
'Cert: ' + svcHealth.cert_days + ' days' : 'Cert: ' + svcHealth.cert_status;
|
||||
healthBadges.push(E('span', {
|
||||
'class': 'sr-badge sr-badge-cert sr-badge-' + svcHealth.cert_status,
|
||||
'title': certTitle
|
||||
}, certIcon));
|
||||
}
|
||||
}
|
||||
|
||||
// SSL/Cert badge (fallback if no health data)
|
||||
if (healthBadges.length === 0 && service.haproxy) {
|
||||
if (service.haproxy.acme) {
|
||||
sslBadge = E('span', { 'class': 'sr-badge sr-badge-acme', 'title': 'ACME Certificate' }, '🔒');
|
||||
healthBadges.push(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' }, '🔐');
|
||||
healthBadges.push(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' }, '🧅');
|
||||
healthBadges.push(E('span', { 'class': 'sr-badge sr-badge-tor', 'title': 'Tor Hidden Service' }, '🧅'));
|
||||
}
|
||||
|
||||
// QR button for published services with URLs
|
||||
@ -161,6 +363,16 @@ return view.extend({
|
||||
}, '📱');
|
||||
}
|
||||
|
||||
// Health check button for published services with domains
|
||||
var checkBtn = null;
|
||||
if (isPublished && domain) {
|
||||
checkBtn = E('button', {
|
||||
'class': 'sr-btn sr-btn-check',
|
||||
'title': 'Check Health',
|
||||
'click': ui.createHandlerFn(this, 'handleServiceHealthCheck', service)
|
||||
}, '🔍');
|
||||
}
|
||||
|
||||
// Action button
|
||||
var actionBtn;
|
||||
if (isPublished) {
|
||||
@ -187,12 +399,24 @@ return view.extend({
|
||||
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-badges' }, healthBadges),
|
||||
E('span', { 'class': 'sr-col-qr' }, [qrBtn, checkBtn].filter(Boolean)),
|
||||
E('span', { 'class': 'sr-col-action' }, actionBtn)
|
||||
]);
|
||||
},
|
||||
|
||||
handleServiceHealthCheck: function(service) {
|
||||
var self = this;
|
||||
var domain = service.haproxy && service.haproxy.domain;
|
||||
if (!domain) return;
|
||||
|
||||
document.getElementById('url-check-domain').value = domain;
|
||||
this.handleUrlCheck();
|
||||
|
||||
// Scroll to the checker
|
||||
document.querySelector('.sr-wizard-card').scrollIntoView({ behavior: 'smooth' });
|
||||
},
|
||||
|
||||
handleShowQR: function(service) {
|
||||
var urls = service.urls || {};
|
||||
var qrBoxes = [];
|
||||
@ -321,6 +545,41 @@ return view.extend({
|
||||
.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; }
|
||||
|
||||
/* Health Summary Bar */
|
||||
.sr-health-bar { display: flex; gap: 15px; margin-bottom: 15px; padding: 10px 15px; background: #f0f7ff; border-radius: 6px; border-left: 4px solid #0099cc; flex-wrap: wrap; }
|
||||
@media (prefers-color-scheme: dark) { .sr-health-bar { background: #1a2a3e; } }
|
||||
.sr-health-item { font-size: 0.9em; }
|
||||
|
||||
/* URL Checker Wizard Card */
|
||||
.sr-wizard-card { background: linear-gradient(135deg, #0a192f 0%, #172a45 100%); border-radius: 12px; padding: 20px; margin-bottom: 25px; color: #fff; }
|
||||
.sr-wizard-header { display: flex; align-items: center; gap: 12px; margin-bottom: 15px; }
|
||||
.sr-wizard-icon { font-size: 1.8em; }
|
||||
.sr-wizard-title { font-size: 1.2em; font-weight: 600; }
|
||||
.sr-wizard-desc { font-size: 0.85em; opacity: 0.7; margin-left: auto; }
|
||||
.sr-wizard-form { display: flex; gap: 10px; align-items: center; }
|
||||
.sr-wizard-input { flex: 1; padding: 10px 15px; border: 1px solid #334155; border-radius: 6px; background: #0f172a; color: #fff; font-size: 1em; }
|
||||
.sr-wizard-input::placeholder { color: #64748b; }
|
||||
|
||||
/* Health Check Results */
|
||||
.sr-check-results { margin-top: 15px; }
|
||||
.sr-check-loading { text-align: center; padding: 20px; font-size: 1.1em; }
|
||||
.sr-check-error { background: #450a0a; padding: 12px 15px; border-radius: 6px; color: #fca5a5; }
|
||||
.sr-check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
|
||||
.sr-check-item { background: #0f172a; padding: 12px 15px; border-radius: 8px; display: flex; align-items: center; gap: 10px; border-left: 3px solid #334155; }
|
||||
.sr-check-item.sr-check-ok { border-left-color: #22c55e; }
|
||||
.sr-check-item.sr-check-warn { border-left-color: #eab308; }
|
||||
.sr-check-item.sr-check-fail { border-left-color: #ef4444; }
|
||||
.sr-check-icon { font-size: 1.3em; }
|
||||
.sr-check-label { font-weight: 600; font-size: 0.9em; min-width: 100px; }
|
||||
.sr-check-value { font-size: 0.85em; opacity: 0.8; }
|
||||
.sr-check-summary { margin-top: 15px; padding: 15px; background: #0f172a; border-radius: 8px; text-align: center; }
|
||||
.sr-check-ready { font-size: 1.1em; color: #22c55e; font-weight: 600; }
|
||||
.sr-check-almost { font-size: 1.1em; color: #eab308; font-weight: 600; }
|
||||
.sr-check-notready { font-size: 1.1em; color: #ef4444; font-weight: 600; margin-bottom: 10px; }
|
||||
.sr-check-tip { font-size: 0.85em; opacity: 0.8; margin-top: 5px; }
|
||||
.sr-check-action { display: inline-block; margin-top: 10px; padding: 8px 16px; background: #0099cc; color: #fff; text-decoration: none; border-radius: 6px; font-size: 0.9em; }
|
||||
.sr-check-action:hover { background: #00b3e6; }
|
||||
|
||||
.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; }
|
||||
@ -345,11 +604,17 @@ return view.extend({
|
||||
.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-badges { width: 80px; display: flex; gap: 4px; }
|
||||
.sr-col-qr { width: 60px; display: flex; gap: 4px; }
|
||||
.sr-col-action { width: 36px; }
|
||||
|
||||
.sr-badge { font-size: 0.85em; }
|
||||
.sr-badge { font-size: 0.85em; cursor: help; }
|
||||
.sr-badge-ok { opacity: 1; }
|
||||
.sr-badge-warning { animation: pulse 2s infinite; }
|
||||
.sr-badge-critical, .sr-badge-expired { animation: pulse 1s infinite; }
|
||||
.sr-badge-missing, .sr-badge-none { opacity: 0.5; }
|
||||
.sr-badge-failed { opacity: 1; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
|
||||
.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); }
|
||||
@ -359,6 +624,8 @@ return view.extend({
|
||||
.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-check { color: #8b5cf6; font-size: 0.9em; }
|
||||
.sr-btn-check:hover { background: rgba(139,92,246,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; }
|
||||
@ -380,6 +647,8 @@ return view.extend({
|
||||
@media (max-width: 768px) {
|
||||
.sr-row { flex-wrap: wrap; }
|
||||
.sr-col-url { flex-basis: 100%; order: 10; margin-top: 5px; }
|
||||
.sr-wizard-form { flex-direction: column; }
|
||||
.sr-wizard-input { width: 100%; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@ -880,6 +880,305 @@ _add_category() {
|
||||
json_close_object
|
||||
}
|
||||
|
||||
# Helper: Check DNS resolution for a domain
|
||||
check_dns_resolution() {
|
||||
local domain="$1"
|
||||
local expected_ip="$2"
|
||||
|
||||
# Try nslookup first (most common on OpenWrt)
|
||||
local resolved_ip
|
||||
if command -v nslookup >/dev/null 2>&1; then
|
||||
resolved_ip=$(nslookup "$domain" 2>/dev/null | grep -A1 "Name:" | grep "Address" | head -1 | awk '{print $2}')
|
||||
[ -z "$resolved_ip" ] && resolved_ip=$(nslookup "$domain" 2>/dev/null | grep "Address" | tail -1 | awk '{print $2}' | grep -v "^$")
|
||||
elif command -v host >/dev/null 2>&1; then
|
||||
resolved_ip=$(host "$domain" 2>/dev/null | grep "has address" | head -1 | awk '{print $4}')
|
||||
fi
|
||||
|
||||
if [ -n "$resolved_ip" ]; then
|
||||
echo "$resolved_ip"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Helper: Get WAN IP address
|
||||
get_wan_ip() {
|
||||
local wan_ip=""
|
||||
# Try to get WAN IP from interface
|
||||
wan_ip=$(uci -q get network.wan.ipaddr)
|
||||
if [ -z "$wan_ip" ]; then
|
||||
# Try to get from ip command
|
||||
wan_ip=$(ip -4 addr show dev eth0 2>/dev/null | grep -oE 'inet [0-9.]+' | head -1 | awk '{print $2}')
|
||||
fi
|
||||
if [ -z "$wan_ip" ]; then
|
||||
# Try to get public IP (uses local IP for comparison)
|
||||
wan_ip=$(ifconfig 2>/dev/null | grep -E "inet addr:[0-9]" | grep -v "127.0.0.1" | head -1 | sed 's/.*inet addr:\([0-9.]*\).*/\1/')
|
||||
fi
|
||||
echo "$wan_ip"
|
||||
}
|
||||
|
||||
# Helper: Check certificate expiry
|
||||
check_cert_expiry() {
|
||||
local domain="$1"
|
||||
local cert_file="/srv/haproxy/certs/${domain}.pem"
|
||||
|
||||
if [ ! -f "$cert_file" ]; then
|
||||
# Try the ACME path
|
||||
cert_file="/etc/acme/${domain}_ecc/${domain}.cer"
|
||||
[ ! -f "$cert_file" ] && cert_file="/etc/acme/${domain}/${domain}.cer"
|
||||
fi
|
||||
|
||||
if [ -f "$cert_file" ]; then
|
||||
# Get expiry date using openssl
|
||||
local expiry_date
|
||||
expiry_date=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)
|
||||
if [ -n "$expiry_date" ]; then
|
||||
# Convert to epoch
|
||||
local expiry_epoch
|
||||
expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null)
|
||||
local now_epoch
|
||||
now_epoch=$(date +%s)
|
||||
local days_left
|
||||
if [ -n "$expiry_epoch" ]; then
|
||||
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
|
||||
echo "$days_left"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Helper: Check if external port is accessible (basic check via firewall rules)
|
||||
check_port_firewall_open() {
|
||||
local port="$1"
|
||||
local rule_name=""
|
||||
|
||||
case "$port" in
|
||||
80) rule_name="HAProxy-HTTP" ;;
|
||||
443) rule_name="HAProxy-HTTPS" ;;
|
||||
esac
|
||||
|
||||
# Check if firewall rule exists and is enabled
|
||||
local i=0
|
||||
while uci -q get firewall.@rule[$i] >/dev/null 2>&1; do
|
||||
local name=$(uci -q get firewall.@rule[$i].name)
|
||||
local enabled=$(uci -q get firewall.@rule[$i].enabled)
|
||||
local dest_port=$(uci -q get firewall.@rule[$i].dest_port)
|
||||
if [ "$name" = "$rule_name" ] || [ "$dest_port" = "$port" ]; then
|
||||
if [ "$enabled" != "0" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check health status for a service
|
||||
method_check_service_health() {
|
||||
local service_id domain
|
||||
|
||||
read -r input
|
||||
json_load "$input"
|
||||
json_get_var service_id service_id
|
||||
json_get_var domain domain ""
|
||||
|
||||
json_init
|
||||
|
||||
if [ -z "$service_id" ] && [ -z "$domain" ]; then
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "service_id or domain is required"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
|
||||
# If only service_id provided, get domain from config
|
||||
if [ -z "$domain" ] && [ -n "$service_id" ]; then
|
||||
config_load "$UCI_CONFIG"
|
||||
config_get domain "$service_id" haproxy_domain ""
|
||||
fi
|
||||
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "service_id" "$service_id"
|
||||
json_add_string "domain" "$domain"
|
||||
|
||||
# DNS check
|
||||
json_add_object "dns"
|
||||
if [ -n "$domain" ]; then
|
||||
local resolved_ip
|
||||
resolved_ip=$(check_dns_resolution "$domain")
|
||||
if [ -n "$resolved_ip" ]; then
|
||||
json_add_string "status" "ok"
|
||||
json_add_string "resolved_ip" "$resolved_ip"
|
||||
else
|
||||
json_add_string "status" "failed"
|
||||
json_add_string "error" "DNS resolution failed"
|
||||
fi
|
||||
else
|
||||
json_add_string "status" "none"
|
||||
fi
|
||||
json_close_object
|
||||
|
||||
# Certificate check
|
||||
json_add_object "certificate"
|
||||
if [ -n "$domain" ]; then
|
||||
local days_left
|
||||
days_left=$(check_cert_expiry "$domain")
|
||||
if [ -n "$days_left" ]; then
|
||||
if [ "$days_left" -lt 0 ]; then
|
||||
json_add_string "status" "expired"
|
||||
elif [ "$days_left" -lt 7 ]; then
|
||||
json_add_string "status" "critical"
|
||||
elif [ "$days_left" -lt 30 ]; then
|
||||
json_add_string "status" "warning"
|
||||
else
|
||||
json_add_string "status" "ok"
|
||||
fi
|
||||
json_add_int "days_left" "$days_left"
|
||||
else
|
||||
json_add_string "status" "missing"
|
||||
fi
|
||||
else
|
||||
json_add_string "status" "none"
|
||||
fi
|
||||
json_close_object
|
||||
|
||||
# Port/Firewall check
|
||||
json_add_object "firewall"
|
||||
local http_open=0
|
||||
local https_open=0
|
||||
check_port_firewall_open 80 && http_open=1
|
||||
check_port_firewall_open 443 && https_open=1
|
||||
|
||||
if [ "$http_open" = "1" ] && [ "$https_open" = "1" ]; then
|
||||
json_add_string "status" "ok"
|
||||
elif [ "$http_open" = "1" ] || [ "$https_open" = "1" ]; then
|
||||
json_add_string "status" "partial"
|
||||
else
|
||||
json_add_string "status" "closed"
|
||||
fi
|
||||
json_add_boolean "http_open" "$http_open"
|
||||
json_add_boolean "https_open" "$https_open"
|
||||
json_close_object
|
||||
|
||||
# HAProxy status
|
||||
json_add_object "haproxy"
|
||||
if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then
|
||||
json_add_string "status" "running"
|
||||
else
|
||||
json_add_string "status" "stopped"
|
||||
fi
|
||||
json_close_object
|
||||
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Batch health check for all published services (for dashboard)
|
||||
method_check_all_health() {
|
||||
json_init
|
||||
json_add_object "health"
|
||||
|
||||
local wan_ip
|
||||
wan_ip=$(get_wan_ip)
|
||||
json_add_string "wan_ip" "$wan_ip"
|
||||
|
||||
# Check HAProxy status
|
||||
json_add_object "haproxy"
|
||||
if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then
|
||||
json_add_string "status" "running"
|
||||
else
|
||||
json_add_string "status" "stopped"
|
||||
fi
|
||||
json_close_object
|
||||
|
||||
# Check Tor status
|
||||
json_add_object "tor"
|
||||
if pgrep -f "/usr/sbin/tor" >/dev/null 2>&1; then
|
||||
json_add_string "status" "running"
|
||||
else
|
||||
json_add_string "status" "stopped"
|
||||
fi
|
||||
json_close_object
|
||||
|
||||
# Check firewall ports
|
||||
json_add_object "firewall"
|
||||
local http_open=0
|
||||
local https_open=0
|
||||
check_port_firewall_open 80 && http_open=1
|
||||
check_port_firewall_open 443 && https_open=1
|
||||
json_add_boolean "http_open" "$http_open"
|
||||
json_add_boolean "https_open" "$https_open"
|
||||
if [ "$http_open" = "1" ] && [ "$https_open" = "1" ]; then
|
||||
json_add_string "status" "ok"
|
||||
elif [ "$http_open" = "1" ] || [ "$https_open" = "1" ]; then
|
||||
json_add_string "status" "partial"
|
||||
else
|
||||
json_add_string "status" "closed"
|
||||
fi
|
||||
json_close_object
|
||||
|
||||
# Check individual services with domains
|
||||
json_add_array "services"
|
||||
|
||||
# Get all published services with domains from HAProxy
|
||||
local vhosts_json
|
||||
vhosts_json=$(ubus call luci.haproxy list_vhosts 2>/dev/null)
|
||||
if [ -n "$vhosts_json" ]; then
|
||||
local count
|
||||
count=$(echo "$vhosts_json" | jsonfilter -e '@.vhosts[*].domain' 2>/dev/null | wc -l)
|
||||
local i=0
|
||||
while [ $i -lt "$count" ]; do
|
||||
local domain enabled
|
||||
domain=$(echo "$vhosts_json" | jsonfilter -e "@.vhosts[$i].domain" 2>/dev/null)
|
||||
enabled=$(echo "$vhosts_json" | jsonfilter -e "@.vhosts[$i].enabled" 2>/dev/null)
|
||||
|
||||
i=$((i + 1))
|
||||
|
||||
[ -z "$domain" ] && continue
|
||||
[ "$enabled" = "false" ] || [ "$enabled" = "0" ] && continue
|
||||
|
||||
json_add_object
|
||||
json_add_string "domain" "$domain"
|
||||
|
||||
# DNS check
|
||||
local resolved_ip
|
||||
resolved_ip=$(check_dns_resolution "$domain")
|
||||
if [ -n "$resolved_ip" ]; then
|
||||
json_add_string "dns_status" "ok"
|
||||
json_add_string "dns_ip" "$resolved_ip"
|
||||
else
|
||||
json_add_string "dns_status" "failed"
|
||||
fi
|
||||
|
||||
# Cert check
|
||||
local days_left
|
||||
days_left=$(check_cert_expiry "$domain")
|
||||
if [ -n "$days_left" ]; then
|
||||
if [ "$days_left" -lt 0 ]; then
|
||||
json_add_string "cert_status" "expired"
|
||||
elif [ "$days_left" -lt 7 ]; then
|
||||
json_add_string "cert_status" "critical"
|
||||
elif [ "$days_left" -lt 30 ]; then
|
||||
json_add_string "cert_status" "warning"
|
||||
else
|
||||
json_add_string "cert_status" "ok"
|
||||
fi
|
||||
json_add_int "cert_days" "$days_left"
|
||||
else
|
||||
json_add_string "cert_status" "missing"
|
||||
fi
|
||||
|
||||
json_close_object
|
||||
done
|
||||
fi
|
||||
|
||||
json_close_array
|
||||
json_close_object
|
||||
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Get certificate status for service
|
||||
method_get_certificate_status() {
|
||||
local service_id
|
||||
@ -908,13 +1207,27 @@ method_get_certificate_status() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Get certificate info from HAProxy
|
||||
local cert_info
|
||||
cert_info=$(ubus call luci.haproxy list_certificates 2>/dev/null)
|
||||
# Get certificate expiry info
|
||||
local days_left
|
||||
days_left=$(check_cert_expiry "$haproxy_domain")
|
||||
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "domain" "$haproxy_domain"
|
||||
json_add_string "status" "unknown"
|
||||
|
||||
if [ -n "$days_left" ]; then
|
||||
if [ "$days_left" -lt 0 ]; then
|
||||
json_add_string "status" "expired"
|
||||
elif [ "$days_left" -lt 7 ]; then
|
||||
json_add_string "status" "critical"
|
||||
elif [ "$days_left" -lt 30 ]; then
|
||||
json_add_string "status" "warning"
|
||||
else
|
||||
json_add_string "status" "valid"
|
||||
fi
|
||||
json_add_int "days_left" "$days_left"
|
||||
else
|
||||
json_add_string "status" "missing"
|
||||
fi
|
||||
|
||||
json_dump
|
||||
}
|
||||
@ -978,6 +1291,8 @@ case "$1" in
|
||||
"get_qr_data": { "service_id": "string", "url_type": "string" },
|
||||
"list_categories": {},
|
||||
"get_certificate_status": { "service_id": "string" },
|
||||
"check_service_health": { "service_id": "string", "domain": "string" },
|
||||
"check_all_health": {},
|
||||
"get_landing_config": {},
|
||||
"save_landing_config": { "auto_regen": "boolean" }
|
||||
}
|
||||
@ -996,6 +1311,8 @@ EOF
|
||||
get_qr_data) method_get_qr_data ;;
|
||||
list_categories) method_list_categories ;;
|
||||
get_certificate_status) method_get_certificate_status ;;
|
||||
check_service_health) method_check_service_health ;;
|
||||
check_all_health) method_check_all_health ;;
|
||||
get_landing_config) method_get_landing_config ;;
|
||||
save_landing_config) method_save_landing_config ;;
|
||||
*)
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
"list_categories",
|
||||
"get_qr_data",
|
||||
"get_certificate_status",
|
||||
"check_service_health",
|
||||
"check_all_health",
|
||||
"get_landing_config"
|
||||
],
|
||||
"luci.haproxy": [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user