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 = `
@@ -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 };