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
204 lines
9.4 KiB
JavaScript
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">×</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;
|
|
}
|