Files
sofarr/client/src/ui/statusPanel.js
T
gronod 9491948ec9
CI / Security audit (push) Successful in 1m45s
Build and Push Docker Image / build (push) Successful in 1m43s
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
fix: resolve Ombi webhook race condition and add Ombi webhook status metrics to status panel
2026-05-24 10:22:47 +01:00

204 lines
9.4 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, STATUS_REFRESH_MS } from '../state.js';
import { refreshStatusPanel as apiRefreshStatusPanel } from '../api.js';
import { fetchWebhookStatus } from './webhooks.js';
export async function toggleStatusPanel() {
const panel = document.getElementById('status-panel');
const webhooksSection = document.getElementById('webhooks-section');
if (!panel.classList.contains('hidden')) {
// Close both panels (webhooks is a sibling, hide it too)
panel.classList.add('hidden');
if (webhooksSection) webhooksSection.classList.add('hidden');
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
return;
}
// Open status panel and webhooks section (siblings)
panel.classList.remove('hidden');
// Show webhooks section for admin users (collapsed by default)
if (webhooksSection && state.isAdmin) {
webhooksSection.classList.remove('hidden');
state.webhookSectionExpanded = false;
document.getElementById('webhooks-content').classList.add('hidden');
document.getElementById('webhooks-toggle').classList.remove('expanded');
await fetchWebhookStatus();
} else if (webhooksSection) {
webhooksSection.classList.add('hidden');
}
refreshStatusPanel();
if (state.statusRefreshHandle) clearInterval(state.statusRefreshHandle);
state.statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
}
export function closeStatusPanel() {
document.getElementById('status-panel').classList.add('hidden');
const webhooksSection = document.getElementById('webhooks-section');
if (webhooksSection) webhooksSection.classList.add('hidden');
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
}
export async function refreshStatusPanel() {
const panel = document.getElementById('status-panel');
const contentDiv = document.getElementById('status-content');
console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display);
if (!panel || panel.classList.contains('hidden')) return;
console.log('[Status] Refreshing status panel...');
try {
const result = await apiRefreshStatusPanel();
if (result.success) {
console.log('[Status] Got status data, rendering...');
renderStatusPanel(result.data, panel);
} else {
console.error('[Status] API returned error:', result.error);
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + result.error + '</p>';
}
}
} catch (err) {
console.error('[Status] Error fetching status:', err);
// Don't overwrite panel on transient error during auto-refresh
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + err.message + '</p>';
}
}
}
export function renderStatusPanel(data, panel) {
console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none');
const s = data.server;
const hrs = Math.floor(s.uptimeSeconds / 3600);
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
const secs = s.uptimeSeconds % 60;
const uptime = `${hrs}h ${mins}m ${secs}s`;
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
let html = `
<div class="status-header">
<h3>Server Status</h3>
<button class="status-close" id="status-close-btn">&times;</button>
</div>
<div class="status-grid">
<div class="status-card">
<div class="status-card-title">Server</div>
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
</div>
<div class="status-card">
<div class="status-card-title">Data Refresh</div>`;
const pollIntervalMs = data.polling.intervalMs;
const clients = data.clients || [];
const sseClients = clients.filter(c => c.type === 'sse');
if (data.polling.enabled) {
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
} else {
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
}
const mode = sseClients.length > 0
? `<span class="status-fg-badge">SSE push</span>`
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
for (const c of sseClients) {
const age = Math.round((Date.now() - c.connectedAt) / 1000);
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
}
html += `</div>`;
// Webhook metrics card (admin only)
if (state.isAdmin && data.webhooks) {
const wh = data.webhooks;
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
const ombiEnabled = wh.ombi?.enabled ? '●' : '○';
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
const radarrEvents = wh.radarr?.eventsReceived || 0;
const ombiEvents = wh.ombi?.eventsReceived || 0;
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
const radarrPolls = wh.radarr?.pollsSkipped || 0;
const ombiPolls = wh.ombi?.pollsSkipped || 0;
html += `
<div class="status-card">
<div class="status-card-title">Webhooks</div>
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row"><span>Ombi</span><span>${ombiEnabled} ${wh.ombi?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents} O:${ombiEvents}</span></div>
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls} O:${ombiPolls}</span></div>
</div>`;
}
// Poll timings card
const lp = data.polling.lastPoll;
if (lp) {
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
<div class="status-timings">`;
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
for (const t of lp.tasks) {
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
html += `
<div class="timing-row">
<span class="timing-label">${escapeHtml(t.label)}</span>
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
<span class="timing-value">${t.ms}ms</span>
</div>`;
}
html += `</div></div>`;
}
// Cache table
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
<table class="status-table">
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
<tbody>`;
for (const e of data.cache.entries) {
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
const items = e.itemCount !== null ? e.itemCount : '—';
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
}
html += `</tbody></table></div></div>`;
// Render into status-content div, not the whole panel (preserves webhooks section)
const contentDiv = document.getElementById('status-content');
const panelCheck = document.getElementById('status-panel');
console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length);
if (panelCheck) {
console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200));
}
if (contentDiv) {
contentDiv.innerHTML = html;
console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length);
} else {
console.error('[Status] contentDiv not found!');
}
// Wire close button — addEventListener avoids CSP inline handler restrictions
const closeBtn = document.getElementById('status-close-btn');
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
el.style.width = el.dataset.w + '%';
});
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}