Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
372 lines
20 KiB
JavaScript
372 lines
20 KiB
JavaScript
// ========== HEALTH CHECK DASHBOARD ==========
|
|
(function() {
|
|
// Inject modal HTML
|
|
injectModal('health-modal', `<div id="health-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
|
<h3>🏥 Health Check Dashboard</h3>
|
|
<p class="modal-subtitle">
|
|
Service uptime tracking, SLA monitoring, and incident management.
|
|
</p>
|
|
|
|
<div class="panel-tabs">
|
|
<button class="panel-tab active" data-panel="health-status">Status</button>
|
|
<button class="panel-tab" data-panel="health-incidents">Incidents</button>
|
|
<button class="panel-tab" data-panel="health-config">Configure</button>
|
|
</div>
|
|
|
|
<!-- Tab: Status -->
|
|
<div id="health-status" class="panel-section active">
|
|
<div id="health-status-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading health status...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Incidents -->
|
|
<div id="health-incidents" class="panel-section">
|
|
<div id="health-incidents-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="empty-icon">🚨</span> Loading incidents...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Configure -->
|
|
<div id="health-config" class="panel-section">
|
|
<div id="health-config-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
|
</div>
|
|
|
|
<!-- Add/Edit Health Check Form -->
|
|
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
|
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
|
<div>
|
|
<label class="text-muted-sm">Service ID</label>
|
|
<input type="text" id="health-form-id" placeholder="e.g. plex" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">Display Name</label>
|
|
<input type="text" id="health-form-name" placeholder="e.g. Plex Media Server" class="form-input" />
|
|
</div>
|
|
<div style="grid-column: span 2;">
|
|
<label class="text-muted-sm">URL</label>
|
|
<input type="text" id="health-form-url" placeholder="https://plex.home" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">Timeout (ms)</label>
|
|
<input type="number" id="health-form-timeout" value="10000" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">Expected Status Codes</label>
|
|
<input type="text" id="health-form-codes" value="200" placeholder="200, 301, 302" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">SLA Target (%)</label>
|
|
<input type="number" id="health-form-sla" value="99.9" step="0.1" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">Slow Response Threshold (ms)</label>
|
|
<input type="number" id="health-form-slow" value="5000" class="form-input" />
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end;">
|
|
<button id="health-form-cancel">Cancel</button>
|
|
<button id="health-form-save" class="btn-accent-solid">Save</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 12px;">
|
|
<button id="health-add-btn" class="btn-accent-solid">+ Add Health Check</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel-bottom-bar">
|
|
<button id="health-refresh-btn" class="btn-sm">🔄 Refresh</button>
|
|
<span id="health-last-update" class="text-auto-right"></span>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="health-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);
|
|
|
|
const modal = document.getElementById('health-modal');
|
|
const openBtn = document.getElementById('health-check-btn');
|
|
const cancelBtn = document.getElementById('health-cancel');
|
|
const refreshBtn = document.getElementById('health-refresh-btn');
|
|
const statusContainer = document.getElementById('health-status-container');
|
|
const incidentsContainer = document.getElementById('health-incidents-container');
|
|
const configContainer = document.getElementById('health-config-container');
|
|
const lastUpdateSpan = document.getElementById('health-last-update');
|
|
const addBtn = document.getElementById('health-add-btn');
|
|
const formEl = document.getElementById('health-config-form');
|
|
const formTitle = document.getElementById('health-form-title');
|
|
const formCancel = document.getElementById('health-form-cancel');
|
|
const formSave = document.getElementById('health-form-save');
|
|
|
|
let editingId = null;
|
|
|
|
function uptimeColor(pct) {
|
|
if (pct >= 99.9) return 'var(--ok-fg)';
|
|
if (pct >= 95) return '#f39c12';
|
|
return 'var(--bad-fg)';
|
|
}
|
|
|
|
function severityBadge(sev) {
|
|
const colors = { critical: 'var(--bad-fg)', high: '#ff6b6b', medium: '#f39c12', low: 'var(--muted)' };
|
|
return `<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${colors[sev] || 'var(--muted)'}20; color: ${colors[sev] || 'var(--muted)'};">${sev}</span>`;
|
|
}
|
|
|
|
async function loadStatus() {
|
|
try {
|
|
const res = await fetch('/api/v1/health-checks/status');
|
|
const data = await res.json();
|
|
if (!data.success || !data.status || Object.keys(data.status).length === 0) {
|
|
statusContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🏥</span>No health checks configured. Go to the Configure tab to add services.</div>';
|
|
return;
|
|
}
|
|
const services = Object.values(data.status);
|
|
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
|
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">';
|
|
html += '<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>';
|
|
html += '<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>';
|
|
html += '<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';
|
|
for (const s of services) {
|
|
const isUp = s.status === 'up';
|
|
const dotColor = isUp ? 'var(--dot-ok)' : 'var(--dot-bad)';
|
|
const u24 = s.uptime?.['24h'] ?? '-';
|
|
const u7d = s.uptime?.['7d'] ?? '-';
|
|
const avgRt = s.avgResponseTime != null ? Math.round(s.avgResponseTime) + 'ms' : '-';
|
|
const lastCheck = s.timestamp ? timeAgo(s.timestamp) : '-';
|
|
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${s.serviceId}">`;
|
|
html += `<td style="padding: 8px; font-weight: 500;">${s.name || s.serviceId}</td>`;
|
|
html += `<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${dotColor}; margin-right: 6px;"></span>${isUp ? 'Up' : 'Down'}</td>`;
|
|
html += `<td style="padding: 8px; color: ${typeof u24 === 'number' ? uptimeColor(u24) : 'var(--muted)'};">${typeof u24 === 'number' ? u24.toFixed(1) + '%' : u24}</td>`;
|
|
html += `<td style="padding: 8px; color: ${typeof u7d === 'number' ? uptimeColor(u7d) : 'var(--muted)'};">${typeof u7d === 'number' ? u7d.toFixed(1) + '%' : u7d}</td>`;
|
|
html += `<td style="padding: 8px;">${avgRt}</td>`;
|
|
html += `<td style="padding: 8px; color: var(--muted);">${lastCheck}</td>`;
|
|
html += '</tr>';
|
|
html += `<tr id="health-detail-${s.serviceId}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`;
|
|
}
|
|
html += '</table>';
|
|
statusContainer.innerHTML = html;
|
|
lastUpdateSpan.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
|
|
|
// Row click to expand details
|
|
statusContainer.querySelectorAll('tr[data-health-id]').forEach(row => {
|
|
row.addEventListener('click', async () => {
|
|
const id = row.dataset.healthId;
|
|
const detailRow = document.getElementById('health-detail-' + id);
|
|
if (!detailRow) return;
|
|
if (detailRow.style.display !== 'none') {
|
|
detailRow.style.display = 'none';
|
|
return;
|
|
}
|
|
detailRow.style.display = '';
|
|
try {
|
|
const r = await fetch(`/api/v1/health-checks/${id}/stats?hours=24`);
|
|
const d = await r.json();
|
|
if (d.success && d.stats) {
|
|
const st = d.stats;
|
|
const rt = st.responseTime || {};
|
|
detailRow.querySelector('td').innerHTML = `
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
|
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${st.totalChecks || 0}</strong></div>
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${uptimeColor(st.uptime || 0)};">${(st.uptime || 0).toFixed(2)}%</strong></div>
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(rt.avg || 0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(rt.p95 || 0)}ms / ${Math.round(rt.p99 || 0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(rt.min || 0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(rt.max || 0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${st.upChecks || 0}</strong></div>
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${st.downChecks || 0}</strong></div>
|
|
</div>`;
|
|
} else {
|
|
detailRow.querySelector('td').innerHTML = '<div class="panel-empty">No detailed stats available for this period.</div>';
|
|
}
|
|
} catch (e) {
|
|
detailRow.querySelector('td').innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
|
}
|
|
});
|
|
});
|
|
} catch (e) {
|
|
statusContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadIncidents() {
|
|
try {
|
|
const [openRes, histRes] = await Promise.all([
|
|
fetch('/api/v1/health-checks/incidents'),
|
|
fetch('/api/v1/health-checks/incidents/history?limit=50')
|
|
]);
|
|
const openData = await openRes.json();
|
|
const histData = await histRes.json();
|
|
let html = '';
|
|
|
|
// Open incidents
|
|
const open = (openData.success && openData.incidents) ? openData.incidents : [];
|
|
if (open.length > 0) {
|
|
html += '<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents (' + open.length + ')</h4>';
|
|
for (const inc of open) {
|
|
html += `<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<span style="font-weight: 500;">${inc.serviceId}</span>
|
|
<span>${severityBadge(inc.severity)}</span>
|
|
</div>
|
|
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${inc.message}</div>
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(inc.createdAt)} · ${inc.occurrences || 1} occurrence(s)</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
} else {
|
|
html += '<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational — no open incidents</div>';
|
|
}
|
|
|
|
// Incident history
|
|
const history = (histData.success && histData.history) ? histData.history : [];
|
|
if (history.length > 0) {
|
|
html += '<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>';
|
|
html += '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
|
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';
|
|
for (const inc of history) {
|
|
const resolved = inc.status === 'resolved';
|
|
const dur = resolved && inc.duration ? (inc.duration < 60000 ? Math.round(inc.duration / 1000) + 's' : Math.round(inc.duration / 60000) + 'm') : '-';
|
|
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
|
html += `<td style="padding: 6px;">${inc.serviceId}</td>`;
|
|
html += `<td style="padding: 6px;">${inc.type}</td>`;
|
|
html += `<td style="padding: 6px;">${severityBadge(inc.severity)}</td>`;
|
|
html += `<td style="padding: 6px;"><span style="color: ${resolved ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${inc.status}</span></td>`;
|
|
html += `<td style="padding: 6px;">${dur}</td>`;
|
|
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(inc.createdAt)}</td>`;
|
|
html += '</tr>';
|
|
}
|
|
html += '</table>';
|
|
}
|
|
|
|
incidentsContainer.innerHTML = html || '<div class="panel-empty"><span class="empty-icon">🚨</span>No incidents recorded yet.</div>';
|
|
} catch (e) {
|
|
incidentsContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadConfig() {
|
|
try {
|
|
const res = await fetch('/api/v1/health-checks/status');
|
|
const data = await res.json();
|
|
const services = data.success && data.status ? Object.values(data.status) : [];
|
|
if (services.length === 0) {
|
|
configContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">⚙️</span>No health checks configured yet. Click "Add Health Check" below.</div>';
|
|
return;
|
|
}
|
|
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
|
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
|
|
for (const s of services) {
|
|
const isUp = s.status === 'up';
|
|
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
|
html += `<td style="padding: 8px; font-weight: 500;">${s.name || s.serviceId}</td>`;
|
|
html += `<td style="padding: 8px; color: ${isUp ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${isUp ? 'Up' : 'Down'}</td>`;
|
|
html += `<td style="padding: 8px;">${s.sla?.target ? s.sla.target + '%' : '-'}</td>`;
|
|
html += `<td style="padding: 8px; text-align: right;">`;
|
|
html += `<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${s.serviceId}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`;
|
|
html += `<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${s.serviceId}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`;
|
|
html += '</td></tr>';
|
|
}
|
|
html += '</table>';
|
|
configContainer.innerHTML = html;
|
|
} catch (e) {
|
|
configContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function showForm(id, name, url, timeout, codes, sla, slow) {
|
|
editingId = id || null;
|
|
formTitle.textContent = id ? 'Edit Health Check' : 'Add Health Check';
|
|
document.getElementById('health-form-id').value = id || '';
|
|
document.getElementById('health-form-id').disabled = !!id;
|
|
document.getElementById('health-form-name').value = name || '';
|
|
document.getElementById('health-form-url').value = url || '';
|
|
document.getElementById('health-form-timeout').value = timeout || 10000;
|
|
document.getElementById('health-form-codes').value = codes || '200';
|
|
document.getElementById('health-form-sla').value = sla || 99.9;
|
|
document.getElementById('health-form-slow').value = slow || 5000;
|
|
formEl.style.display = '';
|
|
addBtn.style.display = 'none';
|
|
}
|
|
|
|
function hideForm() {
|
|
formEl.style.display = 'none';
|
|
addBtn.style.display = '';
|
|
editingId = null;
|
|
}
|
|
|
|
addBtn?.addEventListener('click', () => showForm('', '', '', 10000, '200', 99.9, 5000));
|
|
formCancel?.addEventListener('click', hideForm);
|
|
|
|
formSave?.addEventListener('click', async () => {
|
|
const id = editingId || document.getElementById('health-form-id').value.trim();
|
|
if (!id) return showNotification('Service ID is required', 'warning');
|
|
const url = document.getElementById('health-form-url').value.trim();
|
|
if (!url) return showNotification('URL is required', 'warning');
|
|
|
|
const codes = document.getElementById('health-form-codes').value.split(',').map(c => parseInt(c.trim())).filter(Boolean);
|
|
const body = {
|
|
name: document.getElementById('health-form-name').value.trim() || id,
|
|
url,
|
|
timeout: parseInt(document.getElementById('health-form-timeout').value) || 10000,
|
|
expectedStatusCodes: codes.length ? codes : [200],
|
|
sla: { target: parseFloat(document.getElementById('health-form-sla').value) || 99.9 },
|
|
slowResponseThreshold: parseInt(document.getElementById('health-form-slow').value) || 5000
|
|
};
|
|
|
|
try {
|
|
formSave.textContent = 'Saving...';
|
|
formSave.disabled = true;
|
|
const res = await secureFetch(`/api/v1/health-checks/${encodeURIComponent(id)}/configure`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.error || 'Save failed');
|
|
hideForm();
|
|
loadConfig();
|
|
loadStatus();
|
|
} catch (e) {
|
|
showNotification('Error: ' + e.message, 'error');
|
|
} finally {
|
|
formSave.textContent = 'Save';
|
|
formSave.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('health-edit', async (e) => {
|
|
const id = e.detail;
|
|
// Load existing config to populate form — use current status data as fallback
|
|
showForm(id, '', '', 10000, '200', 99.9, 5000);
|
|
});
|
|
|
|
document.addEventListener('health-delete', async (e) => {
|
|
const id = e.detail;
|
|
if (!confirm(`Delete health check for "${id}"?`)) return;
|
|
try {
|
|
const res = await secureFetch(`/api/v1/health-checks/${encodeURIComponent(id)}/configure`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.error);
|
|
loadConfig();
|
|
loadStatus();
|
|
} catch (err) {
|
|
showNotification('Error: ' + err.message, 'error');
|
|
}
|
|
});
|
|
|
|
openBtn?.addEventListener('click', () => {
|
|
modal?.classList.add('show');
|
|
loadStatus();
|
|
});
|
|
wireModal(modal, cancelBtn);
|
|
refreshBtn?.addEventListener('click', loadStatus);
|
|
|
|
// Lazy-load tabs
|
|
document.querySelector('[data-panel="health-incidents"]')?.addEventListener('click', loadIncidents);
|
|
document.querySelector('[data-panel="health-config"]')?.addEventListener('click', loadConfig);
|
|
})();
|