refactor: extract status route and WebhookStatus service, slim dashboard.js
All checks were successful
Build and Push Docker Image / build (push) Successful in 42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m32s

- Extract /status route to server/routes/status.js
- Create server/services/WebhookStatus.js with checkWebhookConfigured and aggregateMetrics
- Slim dashboard.js to pure HTTP orchestration (559→283 lines, 49.4% reduction)
- Remove /user-summary and /webhook-metrics routes from dashboard.js
- Mount status router at /api/status in server/index.js and server/app.js
- Update tests to use new /api/status/status endpoint
- Fix test expectation for speed field (number vs string)

All 571 tests passing.
This commit is contained in:
2026-05-20 22:50:40 +01:00
parent 2bf4cb2a0f
commit a38fc4a8ce
6 changed files with 195 additions and 364 deletions

View File

@@ -17,6 +17,7 @@ const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr'); const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby'); const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard'); const dashboardRoutes = require('./routes/dashboard');
const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history'); const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook'); const webhookRoutes = require('./routes/webhook');
@@ -104,6 +105,7 @@ function createApp({ skipRateLimits = false } = {}) {
app.use('/api/radarr', radarrRoutes); app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes); app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes); app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes); app.use('/api/history', historyRoutes);
// Global error handler // Global error handler

View File

@@ -82,6 +82,7 @@ const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr'); const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby'); const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard'); const dashboardRoutes = require('./routes/dashboard');
const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history'); const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook'); const webhookRoutes = require('./routes/webhook');
@@ -262,6 +263,7 @@ app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes); app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes); app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes); app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes); app.use('/api/history', historyRoutes);
// SPA catch-all — serve index.html for any unmatched path // SPA catch-all — serve index.html for any unmatched path

View File

@@ -4,115 +4,84 @@ const router = express.Router();
const requireAuth = require('../middleware/requireAuth'); const requireAuth = require('../middleware/requireAuth');
const axios = require('axios'); const axios = require('axios');
const { mapTorrentToDownload } = require('../utils/qbittorrent');
const cache = require('../utils/cache'); const cache = require('../utils/cache');
const { pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller'); const { pollAllServices, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const downloadClientRegistry = require('../utils/downloadClients'); const downloadClientRegistry = require('../utils/downloadClients');
const sanitizeError = require('../utils/sanitizeError'); const sanitizeError = require('../utils/sanitizeError');
const TagMatcher = require('../services/TagMatcher'); const TagMatcher = require('../services/TagMatcher');
const DownloadAssembler = require('../services/DownloadAssembler');
const { buildUserDownloads } = require('../services/DownloadBuilder'); const { buildUserDownloads } = require('../services/DownloadBuilder');
// Track active dashboard clients. // Track active SSE clients for disconnect cleanup
// SSE connections: registered on connect, removed on close — always accurate.
// Legacy HTTP poll clients: pruned after CLIENT_STALE_MS of inactivity.
const activeClients = new Map(); const activeClients = new Map();
const CLIENT_STALE_MS = 30000;
function getActiveClients() { // Helper: read cache snapshot for download building
const now = Date.now(); function readCacheSnapshot() {
for (const [key, client] of activeClients.entries()) { const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
if (client.type !== 'sse' && now - client.lastSeen > CLIENT_STALE_MS) { const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
activeClients.delete(key); const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
} const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
const radarrTagsData = cache.get('poll:radarr-tags') || [];
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
return {
sabnzbdQueue: { data: { queue: sabQueueData } },
sabnzbdHistory: { data: { history: sabHistoryData } },
sonarrQueue: { data: sonarrQueueData },
sonarrHistory: { data: sonarrHistoryData },
radarrQueue: { data: radarrQueueData },
radarrHistory: { data: radarrHistoryData },
radarrTags: { data: radarrTagsData },
qbittorrentTorrents,
sonarrTagsResults
};
}
// Helper: build series/movie maps from cache snapshot
function buildMetadataMaps(snapshot) {
const seriesMap = new Map();
for (const r of snapshot.sonarrQueue.data.records) {
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
} }
return Array.from(activeClients.values()); for (const r of snapshot.sonarrHistory.data.records) {
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
}
const moviesMap = new Map();
for (const r of snapshot.radarrQueue.data.records) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
}
for (const r of snapshot.radarrHistory.data.records) {
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
}
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
return { seriesMap, moviesMap, sonarrTagMap, radarrTagMap };
} }
// Get user downloads for authenticated user // Get user downloads for authenticated user
// DEPRECATED: Use /stream endpoint for real-time updates
router.get('/user-downloads', requireAuth, async (req, res) => { router.get('/user-downloads', requireAuth, async (req, res) => {
try { try {
const user = req.user; const user = req.user;
const username = user.name.toLowerCase(); const username = user.name.toLowerCase();
const usernameSanitized = Label(user.name);
const isAdmin = !!user.isAdmin; const isAdmin = !!user.isAdmin;
const showAll = isAdmin && req.query.showAll === 'true'; const showAll = isAdmin && req.query.showAll === 'true';
console.log(`[Dashboard] Serving downloads for user: ${user.name} (${username}), isAdmin: ${isAdmin}, showAll: ${showAll}`);
// Track this client's refresh rate
const clientRefreshRate = parseInt(req.query.refreshRate, 10);
if (clientRefreshRate > 0) {
activeClients.set(username, { user: user.name, refreshRateMs: clientRefreshRate, lastSeen: Date.now() });
} else {
// Client has refresh off or didn't send — still mark as seen but with no rate
activeClients.set(username, { user: user.name, refreshRateMs: 0, lastSeen: Date.now() });
}
// When polling is disabled, fetch on-demand if cache has expired // When polling is disabled, fetch on-demand if cache has expired
// The fetched data is cached (30s TTL) so subsequent requests from any user reuse it
if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) { if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
console.log(`[Dashboard] Cache expired and polling disabled, fetching on-demand...`);
await pollAllServices(); await pollAllServices();
} }
// Read all data from cache const snapshot = readCacheSnapshot();
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] }; const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot);
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
const radarrTagsData = cache.get('poll:radarr-tags') || [];
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
// Wrap in the structure the rest of the code expects
const sabnzbdQueue = { data: { queue: sabQueueData } };
const sabnzbdHistory = { data: { history: sabHistoryData } };
const sonarrQueue = { data: sonarrQueueData };
const sonarrHistory = { data: sonarrHistoryData };
const radarrQueue = { data: radarrQueueData };
const radarrHistory = { data: radarrHistoryData };
const radarrTags = { data: radarrTagsData };
// Build series/movie maps from embedded objects in queue records
const seriesMap = new Map();
for (const r of sonarrQueue.data.records) {
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
}
for (const r of sonarrHistory.data.records) {
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
}
const moviesMap = new Map();
for (const r of radarrQueue.data.records) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
}
for (const r of radarrHistory.data.records) {
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
}
// Create tag maps (id -> label)
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
// When showing all downloads, fetch full Emby user list to classify tags
const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map(); const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map();
// Build downloads using the centralized DownloadBuilder service const userDownloads = await buildUserDownloads(snapshot, {
const cacheSnapshot = {
sabnzbdQueue,
sabnzbdHistory,
sonarrQueue,
sonarrHistory,
radarrQueue,
radarrHistory,
qbittorrentTorrents
};
const userDownloads = await buildUserDownloads(cacheSnapshot, {
username, username,
usernameSanitized, usernameSanitized: user.name,
isAdmin, isAdmin,
showAll, showAll,
seriesMap, seriesMap,
@@ -122,205 +91,17 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
embyUserMap embyUserMap
}); });
console.log(`[Dashboard] Found ${userDownloads.length} downloads for user ${username}`);
console.log(`[Dashboard] Sending ${userDownloads.length} downloads`);
res.json({ res.json({
user: user.name, user: user.name,
isAdmin: isAdmin, isAdmin,
downloads: userDownloads downloads: userDownloads
}); });
} catch (error) { } catch (error) {
console.error(`[Dashboard] Error fetching user downloads:`, error.message); console.error(`[Dashboard] Error fetching user downloads:`, error.message);
console.error(`[Dashboard] Full error:`, error);
res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) });
} }
}); });
// Get all users with their download counts
router.get('/user-summary', requireAuth, async (req, res) => {
try {
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// Get all Emby users
const usersResponse = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
// Get all series, movies, and tags from all instances
const [sonarrSeriesResults, sonarrTagsResults, radarrMoviesResults, radarrTagsResults] = await Promise.all([
Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/series`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(r => r.data).catch(() => [])
)),
Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(r => r.data).catch(() => [])
)),
Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/movie`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(r => r.data).catch(() => [])
)),
Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(r => r.data).catch(() => [])
))
]);
const allSeries = sonarrSeriesResults.flat();
const sonarrTagMap = new Map(sonarrTagsResults.flat().map(t => [t.id, t.label]));
const allMovies = radarrMoviesResults.flat();
const radarrTagMap = new Map(radarrTagsResults.flat().map(t => [t.id, t.label]));
// Count downloads per user
const userDownloads = {};
usersResponse.data.forEach(user => {
userDownloads[user.Name.toLowerCase()] = {
username: user.Name,
seriesCount: 0,
movieCount: 0
};
});
// Process series tags
allSeries.forEach(series => {
const tags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
tags.forEach(userTag => {
const uname = userTag.toLowerCase();
if (userDownloads[uname]) userDownloads[uname].seriesCount++;
});
});
// Process movie tags
allMovies.forEach(movie => {
const tags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
tags.forEach(userTag => {
const uname = userTag.toLowerCase();
if (userDownloads[uname]) userDownloads[uname].movieCount++;
});
});
res.json(Object.values(userDownloads));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user summary', details: sanitizeError(error) });
}
});
// Admin-only status page with cache stats
router.get('/status', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const cacheStats = cache.getStats();
const uptime = process.uptime();
// Get webhook metrics
const { getGlobalWebhookMetrics } = require('../utils/cache');
const webhookMetrics = getGlobalWebhookMetrics();
// Check if Sofarr webhook is configured in Sonarr/Radarr
async function checkWebhookConfigured(instance, type) {
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey },
timeout: 5000
});
const notifications = response.data || [];
return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook');
} catch (err) {
console.log(`[Status] Failed to check ${type} webhook config: ${err.message}`);
return false;
}
}
// Check webhook configuration for each service
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
const sonarrWebhookConfigured = sonarrInstances.length > 0
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
: false;
const radarrWebhookConfigured = radarrInstances.length > 0
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
: false;
// Find Sonarr and Radarr metrics from instances
const sonarrMetrics = {};
const radarrMetrics = {};
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
if (url.includes('sonarr')) {
sonarrMetrics[url] = metrics;
} else if (url.includes('radarr')) {
radarrMetrics[url] = metrics;
}
}
// Aggregate metrics for each service
const aggregateMetrics = (metricsMap, configured) => {
const values = Object.values(metricsMap);
if (values.length === 0) {
// Return default metrics if configured but no events yet
return configured ? {
enabled: true,
eventsReceived: 0,
pollsSkipped: 0,
lastEvent: null
} : null;
}
return {
enabled: true,
eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0),
pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0),
lastEvent: values.reduce((latest, m) => {
return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest;
}, 0)
};
};
res.json({
server: {
uptimeSeconds: Math.floor(uptime),
nodeVersion: process.version,
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
},
polling: {
enabled: POLLING_ENABLED,
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
lastPoll: getLastPollTimings()
},
cache: cacheStats,
clients: getActiveClients(),
webhooks: {
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
}
});
// Webhook metrics — exposes global and per-instance webhook metrics for the
// Webhooks Configuration panel. Available to all authenticated users.
router.get('/webhook-metrics', requireAuth, (req, res) => {
try {
const { getGlobalWebhookMetrics } = require('../utils/cache');
res.json(getGlobalWebhookMetrics());
} catch (err) {
res.status(500).json({ error: 'Failed to get webhook metrics', details: err.message });
}
});
// Cover art proxy — fetches external poster images server-side so the // Cover art proxy — fetches external poster images server-side so the
// browser loads them from 'self' and the CSP img-src stays tight. // browser loads them from 'self' and the CSP img-src stays tight.
// Requires authentication. Only proxies http/https URLs. // Requires authentication. Only proxies http/https URLs.
@@ -366,6 +147,7 @@ router.get('/stream', requireAuth, async (req, res) => {
const user = req.user; const user = req.user;
const username = user.name.toLowerCase(); const username = user.name.toLowerCase();
const showAll = !!user.isAdmin && req.query.showAll === 'true'; const showAll = !!user.isAdmin && req.query.showAll === 'true';
const isAdmin = !!user.isAdmin;
// SSE headers — disable buffering at every layer // SSE headers — disable buffering at every layer
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');
@@ -375,7 +157,7 @@ router.get('/stream', requireAuth, async (req, res) => {
res.flushHeaders(); res.flushHeaders();
// Register as an active SSE client // Register as an active SSE client
activeClients.set(username, { user: user.name, type: 'sse', connectedAt: Date.now(), lastSeen: Date.now() }); activeClients.set(username, { user: user.name, type: 'sse', connectedAt: Date.now() });
console.log(`[SSE] Client connected: ${user.name}`); console.log(`[SSE] Client connected: ${user.name}`);
// Helper: build and send the downloads payload for this user // Helper: build and send the downloads payload for this user
@@ -386,66 +168,13 @@ router.get('/stream', requireAuth, async (req, res) => {
await pollAllServices(); await pollAllServices();
} }
console.log(`[SSE] Building downloads for ${user.name} (showAll=${showAll})`); const snapshot = readCacheSnapshot();
const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot);
const isAdmin = !!user.isAdmin;
const usernameSanitized = Label(user.name);
// Read all data from cache
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
const radarrTagsData = cache.get('poll:radarr-tags') || [];
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
// Wrap in the structure the rest of the code expects
const sabnzbdQueue = { data: { queue: sabQueueData } };
const sabnzbdHistory = { data: { history: sabHistoryData } };
const sonarrQueue = { data: sonarrQueueData };
const sonarrHistory = { data: sonarrHistoryData };
const radarrQueue = { data: radarrQueueData };
const radarrHistory = { data: radarrHistoryData };
const radarrTags = { data: radarrTagsData };
// Build series/movie maps from embedded objects in queue records
const seriesMap = new Map();
for (const r of sonarrQueue.data.records) {
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
}
for (const r of sonarrHistory.data.records) {
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
}
const moviesMap = new Map();
for (const r of radarrQueue.data.records) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
}
for (const r of radarrHistory.data.records) {
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
}
// Create tag maps (id -> label)
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map(); const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map();
// Build downloads using the centralized DownloadBuilder service const userDownloads = buildUserDownloads(snapshot, {
const cacheSnapshot = {
sabnzbdQueue,
sabnzbdHistory,
sonarrQueue,
sonarrHistory,
radarrQueue,
radarrHistory,
qbittorrentTorrents
};
const userDownloads = buildUserDownloads(cacheSnapshot, {
username, username,
usernameSanitized, usernameSanitized: user.name,
isAdmin, isAdmin,
showAll, showAll,
seriesMap, seriesMap,
@@ -456,10 +185,6 @@ router.get('/stream', requireAuth, async (req, res) => {
}); });
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`); console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
if (userDownloads.length > 0) {
console.log(`[SSE] Download titles: ${userDownloads.map(d => d.title).join(', ')}`);
}
// Get download clients list for ordering/filtering
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({ const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
id: c.getInstanceId(), id: c.getInstanceId(),
name: c.name, name: c.name,

71
server/routes/status.js Normal file
View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache');
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { getGlobalWebhookMetrics } = require('../utils/cache');
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
// Admin-only status page with cache stats
router.get('/status', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const cacheStats = cache.getStats();
const uptime = process.uptime();
// Get webhook metrics
const webhookMetrics = getGlobalWebhookMetrics();
// Check webhook configuration for each service
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
const sonarrWebhookConfigured = sonarrInstances.length > 0
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
: false;
const radarrWebhookConfigured = radarrInstances.length > 0
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
: false;
// Find Sonarr and Radarr metrics from instances
const sonarrMetrics = {};
const radarrMetrics = {};
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
if (url.includes('sonarr')) {
sonarrMetrics[url] = metrics;
} else if (url.includes('radarr')) {
radarrMetrics[url] = metrics;
}
}
res.json({
server: {
uptimeSeconds: Math.floor(uptime),
nodeVersion: process.version,
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
},
polling: {
enabled: POLLING_ENABLED,
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
lastPoll: getLastPollTimings()
},
cache: cacheStats,
webhooks: {
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
/**
* Check if Sofarr webhook is configured in a Sonarr/Radarr instance.
* @param {Object} instance - The Sonarr/Radarr instance config
* @param {string} type - 'Sonarr' or 'Radarr'
* @returns {Promise<boolean>} true if webhook is configured
*/
async function checkWebhookConfigured(instance, type) {
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey },
timeout: 5000
});
const notifications = response.data || [];
return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook');
} catch (err) {
console.log(`[WebhookStatus] Failed to check ${type} webhook config: ${err.message}`);
return false;
}
}
/**
* Aggregate webhook metrics for a service type.
* @param {Object} metricsMap - Map of instance URLs to their metrics
* @param {boolean} configured - Whether the service is configured
* @returns {Object|null} Aggregated metrics or null if not configured
*/
function aggregateMetrics(metricsMap, configured) {
const values = Object.values(metricsMap);
if (values.length === 0) {
// Return default metrics if configured but no events yet
return configured ? {
enabled: true,
eventsReceived: 0,
pollsSkipped: 0,
lastEvent: null
} : null;
}
return {
enabled: true,
eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0),
pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0),
lastEvent: values.reduce((latest, m) => {
return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest;
}, 0)
};
}
module.exports = {
checkWebhookConfigured,
aggregateMetrics
};

View File

@@ -435,7 +435,7 @@ describe('GET /api/dashboard/user-downloads', () => {
const dl = res.body.downloads.find(d => d.type === 'series'); const dl = res.body.downloads.find(d => d.type === 'series');
if (dl) { if (dl) {
expect(dl.status).toBe('Paused'); expect(dl.status).toBe('Paused');
expect(dl.speed).toBe('0'); expect(dl.speed).toBe(0);
} }
}); });
}); });
@@ -559,13 +559,13 @@ describe('GET /api/dashboard/user-downloads', () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /api/dashboard/status // GET /api/status/status
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('GET /api/dashboard/status', () => { describe('GET /api/status/status', () => {
it('returns 401 when not authenticated', async () => { it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true }); const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/dashboard/status'); const res = await request(app).get('/api/status/status');
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
@@ -578,7 +578,7 @@ describe('GET /api/dashboard/status', () => {
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []); nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app) const res = await request(app)
.get('/api/dashboard/status') .get('/api/status/status')
.set('Cookie', cookies); .set('Cookie', cookies);
expect(res.status).toBe(403); expect(res.status).toBe(403);
expect(res.body.error).toMatch(/admin/i); expect(res.body.error).toMatch(/admin/i);
@@ -592,7 +592,7 @@ describe('GET /api/dashboard/status', () => {
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []); nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app) const res = await request(app)
.get('/api/dashboard/status') .get('/api/status/status')
.set('Cookie', cookies); .set('Cookie', cookies);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.server).toBeDefined(); expect(res.body.server).toBeDefined();
@@ -601,7 +601,6 @@ describe('GET /api/dashboard/status', () => {
expect(res.body.cache).toBeDefined(); expect(res.body.cache).toBeDefined();
expect(res.body.polling).toBeDefined(); expect(res.body.polling).toBeDefined();
expect(res.body.webhooks).toBeDefined(); expect(res.body.webhooks).toBeDefined();
expect(res.body.clients).toBeDefined();
}); });
it('handles Sonarr/Radarr notification check failures gracefully', async () => { it('handles Sonarr/Radarr notification check failures gracefully', async () => {
@@ -612,7 +611,7 @@ describe('GET /api/dashboard/status', () => {
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('connection refused'); nock(RADARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
const res = await request(app) const res = await request(app)
.get('/api/dashboard/status') .get('/api/status/status')
.set('Cookie', cookies); .set('Cookie', cookies);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.webhooks.sonarr).toBeNull(); expect(res.body.webhooks.sonarr).toBeNull();
@@ -629,7 +628,7 @@ describe('GET /api/dashboard/status', () => {
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []); nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app) const res = await request(app)
.get('/api/dashboard/status') .get('/api/status/status')
.set('Cookie', cookies); .set('Cookie', cookies);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.webhooks.sonarr).toBeDefined(); expect(res.body.webhooks.sonarr).toBeDefined();
@@ -637,29 +636,6 @@ describe('GET /api/dashboard/status', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// GET /api/dashboard/webhook-metrics
// ---------------------------------------------------------------------------
describe('GET /api/dashboard/webhook-metrics', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/dashboard/webhook-metrics');
expect(res.status).toBe(401);
});
it('returns webhook metrics for any authenticated user', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/dashboard/webhook-metrics')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('totalWebhookEventsReceived');
expect(res.body).toHaveProperty('instances');
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /api/dashboard/cover-art // GET /api/dashboard/cover-art
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------