feat: live-updating status panel with per-task poll timings
All checks were successful
Build and Push Docker Image / build (push) Successful in 28s

- Each service fetch is individually timed (SABnzbd, Sonarr, Radarr, qBit)
- Status panel shows timing bar chart with ms per task and total
- Shows 'Last Poll' age that updates live
- Shows client refresh rate (1s/5s/10s/Off)
- Status panel auto-refreshes in sync with dashboard refresh cycle
- Changing refresh rate restarts the status panel refresh too
- TTL counters update live on each refresh
This commit is contained in:
2026-05-15 23:58:10 +01:00
parent c97f232290
commit 5ae6af114e
4 changed files with 160 additions and 40 deletions

View File

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