diff --git a/public/app.js b/public/app.js index 208092e..6b16970 100644 --- a/public/app.js +++ b/public/app.js @@ -51,6 +51,14 @@ function handleRefreshRateChange(e) { const rate = parseInt(e.target.value); currentRefreshRate = rate; startAutoRefresh(); + // Restart status panel refresh if it's open + const statusPanel = document.getElementById('status-panel'); + if (statusPanel && statusPanel.style.display !== 'none') { + if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } + if (currentRefreshRate > 0) { + statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate); + } + } } function handleShowAllToggle(e) { @@ -569,21 +577,42 @@ function escapeHtml(str) { return div.innerHTML; } +let statusRefreshHandle = null; + async function toggleStatusPanel() { const panel = document.getElementById('status-panel'); if (panel.style.display !== 'none') { panel.style.display = 'none'; + if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } return; } panel.style.display = 'block'; - panel.innerHTML = '

Loading status…

'; + await refreshStatusPanel(); + // Auto-refresh in sync with dashboard refresh rate + if (statusRefreshHandle) clearInterval(statusRefreshHandle); + if (currentRefreshRate > 0) { + statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate); + } +} + +function closeStatusPanel() { + document.getElementById('status-panel').style.display = 'none'; + if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } +} + +async function refreshStatusPanel() { + const panel = document.getElementById('status-panel'); + if (!panel || panel.style.display === 'none') return; try { const res = await fetch('/api/dashboard/status'); if (!res.ok) throw new Error('Failed to fetch status'); const data = await res.json(); renderStatusPanel(data, panel); } catch (err) { - panel.innerHTML = '

Failed to load status.

'; + // Don't overwrite panel on transient error during auto-refresh + if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) { + panel.innerHTML = '

Failed to load status.

'; + } } } @@ -595,11 +624,12 @@ function renderStatusPanel(data, panel) { const uptime = `${hrs}h ${mins}m ${secs}s`; const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1); + const refreshLabel = currentRefreshRate > 0 ? (currentRefreshRate / 1000) + 's' : 'Off'; let html = `

Server Status

- +
@@ -613,7 +643,31 @@ function renderStatusPanel(data, panel) {
Polling
Mode${data.polling.enabled ? 'Background' : 'On-demand'}
${data.polling.enabled ? `
Interval${data.polling.intervalMs / 1000}s
` : ''} -
+
Client refresh${refreshLabel}
+
`; + + // Poll timings card + const lp = data.polling.lastPoll; + if (lp) { + const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000); + html += ` +
+
Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)
+
`; + for (const t of lp.tasks) { + const barWidth = lp.totalMs > 0 ? Math.max(2, (t.ms / lp.totalMs) * 100) : 0; + html += ` +
+ ${escapeHtml(t.label)} +
+ ${t.ms}ms +
`; + } + html += `
`; + } + + // Cache table + html += `
Cache (${data.cache.entryCount} entries, ${totalKB} KB)
diff --git a/public/style.css b/public/style.css index 8087fe5..74e7bed 100644 --- a/public/style.css +++ b/public/style.css @@ -862,6 +862,51 @@ body { font-size: 0.7rem; } +.status-timings { + display: flex; + flex-direction: column; + gap: 6px; +} + +.timing-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; +} + +.timing-label { + width: 110px; + flex-shrink: 0; + color: var(--text-secondary); + white-space: nowrap; +} + +.timing-bar-bg { + flex: 1; + height: 8px; + background: var(--border); + border-radius: 4px; + overflow: hidden; +} + +.timing-bar { + height: 100%; + background: var(--accent); + border-radius: 4px; + min-width: 2px; + transition: width 0.3s ease; +} + +.timing-value { + width: 50px; + flex-shrink: 0; + text-align: right; + color: var(--text-primary); + font-weight: 500; + font-size: 0.75rem; +} + .status-loading, .status-error { text-align: center; padding: 20px; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index adddab8..640d57b 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -3,7 +3,7 @@ const router = express.Router(); const { mapTorrentToDownload } = require('../utils/qbittorrent'); const cache = require('../utils/cache'); -const { pollAllServices, POLLING_ENABLED } = require('../utils/poller'); +const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller'); const EMBY_URL = process.env.EMBY_URL; const EMBY_API_KEY = process.env.EMBY_API_KEY; @@ -625,7 +625,8 @@ router.get('/status', (req, res) => { }, polling: { enabled: POLLING_ENABLED, - intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0 + intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0, + lastPoll: getLastPollTimings() }, cache: cacheStats }); diff --git a/server/utils/poller.js b/server/utils/poller.js index 205ad03..671d4df 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -14,6 +14,14 @@ const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' const POLLING_ENABLED = POLL_INTERVAL > 0; let polling = false; +let lastPollTimings = null; + +// Timed fetch helper: runs a fetch and records how long it took +async function timed(label, fn) { + const t0 = Date.now(); + const result = await fn(); + return { label, result, ms: Date.now() - t0 }; +} async function pollAllServices() { if (polling) { @@ -28,40 +36,33 @@ async function pollAllServices() { const sonarrInstances = getSonarrInstances(); const radarrInstances = getRadarrInstances(); - // All fetches in parallel - const [ - sabQueues, sabHistories, - sonarrTagsResults, sonarrQueues, sonarrHistories, sonarrSeriesResults, - radarrQueues, radarrHistories, radarrMoviesResults, radarrTagsResults, - qbittorrentTorrents - ] = await Promise.all([ - // SABnzbd - Promise.all(sabInstances.map(inst => + // All fetches in parallel, each individually timed + const results = await Promise.all([ + timed('SABnzbd Queue', () => Promise.all(sabInstances.map(inst => axios.get(`${inst.url}/api`, { params: { mode: 'queue', apikey: inst.apiKey, output: 'json' } }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message); return { instance: inst.id, data: { queue: { slots: [] } } }; }) - )), - Promise.all(sabInstances.map(inst => + ))), + timed('SABnzbd History', () => Promise.all(sabInstances.map(inst => axios.get(`${inst.url}/api`, { params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 20 } }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message); return { instance: inst.id, data: { history: { slots: [] } } }; }) - )), - // Sonarr - Promise.all(sonarrInstances.map(inst => + ))), + timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst => axios.get(`${inst.url}/api/v3/tag`, { headers: { 'X-Api-Key': inst.apiKey } }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message); return { instance: inst.id, data: [] }; }) - )), - Promise.all(sonarrInstances.map(inst => + ))), + timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst => axios.get(`${inst.url}/api/v3/queue`, { headers: { 'X-Api-Key': inst.apiKey }, params: { includeSeries: true, includeEpisode: true } @@ -69,8 +70,8 @@ async function pollAllServices() { console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message); return { instance: inst.id, data: { records: [] } }; }) - )), - Promise.all(sonarrInstances.map(inst => + ))), + timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst => axios.get(`${inst.url}/api/v3/history`, { headers: { 'X-Api-Key': inst.apiKey }, params: { pageSize: 20, includeSeries: true, includeEpisode: true } @@ -78,17 +79,16 @@ async function pollAllServices() { console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message); return { instance: inst.id, data: { records: [] } }; }) - )), - Promise.all(sonarrInstances.map(inst => + ))), + timed('Sonarr Series', () => Promise.all(sonarrInstances.map(inst => axios.get(`${inst.url}/api/v3/series`, { headers: { 'X-Api-Key': inst.apiKey } }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { console.error(`[Poller] Sonarr ${inst.id} series error:`, err.message); return { instance: inst.id, data: [] }; }) - )), - // Radarr - Promise.all(radarrInstances.map(inst => + ))), + timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst => axios.get(`${inst.url}/api/v3/queue`, { headers: { 'X-Api-Key': inst.apiKey }, params: { includeMovie: true } @@ -96,8 +96,8 @@ async function pollAllServices() { console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message); return { instance: inst.id, data: { records: [] } }; }) - )), - Promise.all(radarrInstances.map(inst => + ))), + timed('Radarr History', () => Promise.all(radarrInstances.map(inst => axios.get(`${inst.url}/api/v3/history`, { headers: { 'X-Api-Key': inst.apiKey }, params: { pageSize: 20, includeMovie: true } @@ -105,30 +105,46 @@ async function pollAllServices() { console.error(`[Poller] Radarr ${inst.id} history error:`, err.message); return { instance: inst.id, data: { records: [] } }; }) - )), - Promise.all(radarrInstances.map(inst => + ))), + timed('Radarr Movies', () => Promise.all(radarrInstances.map(inst => axios.get(`${inst.url}/api/v3/movie`, { headers: { 'X-Api-Key': inst.apiKey } }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { console.error(`[Poller] Radarr ${inst.id} movies error:`, err.message); return { instance: inst.id, data: [] }; }) - )), - Promise.all(radarrInstances.map(inst => + ))), + timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst => axios.get(`${inst.url}/api/v3/tag`, { headers: { 'X-Api-Key': inst.apiKey } }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message); return { instance: inst.id, data: [] }; }) - )), - // qBittorrent - getTorrents().catch(err => { + ))), + timed('qBittorrent', () => getTorrents().catch(err => { console.error(`[Poller] qBittorrent error:`, err.message); return []; - }) + })) ]); + const [ + { result: sabQueues }, { result: sabHistories }, + { result: sonarrTagsResults }, { result: sonarrQueues }, + { result: sonarrHistories }, { result: sonarrSeriesResults }, + { result: radarrQueues }, { result: radarrHistories }, + { result: radarrMoviesResults }, { result: radarrTagsResults }, + { result: qbittorrentTorrents } + ] = results; + + // Store per-task timings + const totalMs = Date.now() - start; + lastPollTimings = { + totalMs, + timestamp: new Date().toISOString(), + tasks: results.map(r => ({ label: r.label, ms: r.ms })) + }; + // When polling is active, TTL is 3x interval to avoid gaps between polls // When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000; @@ -205,4 +221,8 @@ function stopPoller() { } } -module.exports = { startPoller, stopPoller, pollAllServices, POLL_INTERVAL, POLLING_ENABLED }; +function getLastPollTimings() { + return lastPollTimings; +} + +module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, POLL_INTERVAL, POLLING_ENABLED };