From a38fc4a8ce0bff416d0a85c77cd740aeb2da1da6 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 20 May 2026 22:50:40 +0100 Subject: [PATCH] refactor: extract status route and WebhookStatus service, slim dashboard.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- server/app.js | 2 + server/index.js | 2 + server/routes/dashboard.js | 389 ++++------------------------ server/routes/status.js | 71 +++++ server/services/WebhookStatus.js | 55 ++++ tests/integration/dashboard.test.js | 40 +-- 6 files changed, 195 insertions(+), 364 deletions(-) create mode 100644 server/routes/status.js create mode 100644 server/services/WebhookStatus.js diff --git a/server/app.js b/server/app.js index 8a67fb7..31e098c 100644 --- a/server/app.js +++ b/server/app.js @@ -17,6 +17,7 @@ const sonarrRoutes = require('./routes/sonarr'); const radarrRoutes = require('./routes/radarr'); const embyRoutes = require('./routes/emby'); const dashboardRoutes = require('./routes/dashboard'); +const statusRoutes = require('./routes/status'); const historyRoutes = require('./routes/history'); const authRoutes = require('./routes/auth'); const webhookRoutes = require('./routes/webhook'); @@ -104,6 +105,7 @@ function createApp({ skipRateLimits = false } = {}) { app.use('/api/radarr', radarrRoutes); app.use('/api/emby', embyRoutes); app.use('/api/dashboard', dashboardRoutes); + app.use('/api/status', statusRoutes); app.use('/api/history', historyRoutes); // Global error handler diff --git a/server/index.js b/server/index.js index fa25c03..3e1206e 100644 --- a/server/index.js +++ b/server/index.js @@ -82,6 +82,7 @@ const sonarrRoutes = require('./routes/sonarr'); const radarrRoutes = require('./routes/radarr'); const embyRoutes = require('./routes/emby'); const dashboardRoutes = require('./routes/dashboard'); +const statusRoutes = require('./routes/status'); const historyRoutes = require('./routes/history'); const authRoutes = require('./routes/auth'); const webhookRoutes = require('./routes/webhook'); @@ -262,6 +263,7 @@ app.use('/api/sonarr', sonarrRoutes); app.use('/api/radarr', radarrRoutes); app.use('/api/emby', embyRoutes); app.use('/api/dashboard', dashboardRoutes); +app.use('/api/status', statusRoutes); app.use('/api/history', historyRoutes); // SPA catch-all — serve index.html for any unmatched path diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index e5c8e72..a5293ec 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -4,115 +4,84 @@ const router = express.Router(); const requireAuth = require('../middleware/requireAuth'); const axios = require('axios'); -const { mapTorrentToDownload } = require('../utils/qbittorrent'); const cache = require('../utils/cache'); -const { pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller'); -const { getSonarrInstances, getRadarrInstances } = require('../utils/config'); +const { pollAllServices, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller'); const downloadClientRegistry = require('../utils/downloadClients'); const sanitizeError = require('../utils/sanitizeError'); const TagMatcher = require('../services/TagMatcher'); -const DownloadAssembler = require('../services/DownloadAssembler'); const { buildUserDownloads } = require('../services/DownloadBuilder'); -// Track active dashboard clients. -// SSE connections: registered on connect, removed on close — always accurate. -// Legacy HTTP poll clients: pruned after CLIENT_STALE_MS of inactivity. +// Track active SSE clients for disconnect cleanup const activeClients = new Map(); -const CLIENT_STALE_MS = 30000; -function getActiveClients() { - const now = Date.now(); - for (const [key, client] of activeClients.entries()) { - if (client.type !== 'sse' && now - client.lastSeen > CLIENT_STALE_MS) { - activeClients.delete(key); - } +// Helper: read cache snapshot for download building +function readCacheSnapshot() { + 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') || []; + + 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 +// DEPRECATED: Use /stream endpoint for real-time updates router.get('/user-downloads', requireAuth, async (req, res) => { try { const user = req.user; const username = user.name.toLowerCase(); - const usernameSanitized = Label(user.name); const isAdmin = !!user.isAdmin; 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 - // The fetched data is cached (30s TTL) so subsequent requests from any user reuse it if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) { - console.log(`[Dashboard] Cache expired and polling disabled, fetching on-demand...`); await pollAllServices(); } - // 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])); - - // When showing all downloads, fetch full Emby user list to classify tags + const snapshot = readCacheSnapshot(); + const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot); const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map(); - // Build downloads using the centralized DownloadBuilder service - const cacheSnapshot = { - sabnzbdQueue, - sabnzbdHistory, - sonarrQueue, - sonarrHistory, - radarrQueue, - radarrHistory, - qbittorrentTorrents - }; - const userDownloads = await buildUserDownloads(cacheSnapshot, { + const userDownloads = await buildUserDownloads(snapshot, { username, - usernameSanitized, + usernameSanitized: user.name, isAdmin, showAll, seriesMap, @@ -122,205 +91,17 @@ router.get('/user-downloads', requireAuth, async (req, res) => { embyUserMap }); - console.log(`[Dashboard] Found ${userDownloads.length} downloads for user ${username}`); - console.log(`[Dashboard] Sending ${userDownloads.length} downloads`); - res.json({ user: user.name, - isAdmin: isAdmin, + isAdmin, downloads: userDownloads }); } catch (error) { 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) }); } }); -// 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 // browser loads them from 'self' and the CSP img-src stays tight. // Requires authentication. Only proxies http/https URLs. @@ -366,6 +147,7 @@ router.get('/stream', requireAuth, async (req, res) => { const user = req.user; const username = user.name.toLowerCase(); const showAll = !!user.isAdmin && req.query.showAll === 'true'; + const isAdmin = !!user.isAdmin; // SSE headers — disable buffering at every layer res.setHeader('Content-Type', 'text/event-stream'); @@ -375,7 +157,7 @@ router.get('/stream', requireAuth, async (req, res) => { res.flushHeaders(); // 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}`); // Helper: build and send the downloads payload for this user @@ -386,66 +168,13 @@ router.get('/stream', requireAuth, async (req, res) => { await pollAllServices(); } - console.log(`[SSE] Building downloads for ${user.name} (showAll=${showAll})`); - - 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 snapshot = readCacheSnapshot(); + const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot); const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map(); - // Build downloads using the centralized DownloadBuilder service - const cacheSnapshot = { - sabnzbdQueue, - sabnzbdHistory, - sonarrQueue, - sonarrHistory, - radarrQueue, - radarrHistory, - qbittorrentTorrents - }; - const userDownloads = buildUserDownloads(cacheSnapshot, { + const userDownloads = buildUserDownloads(snapshot, { username, - usernameSanitized, + usernameSanitized: user.name, isAdmin, showAll, seriesMap, @@ -456,10 +185,6 @@ router.get('/stream', requireAuth, async (req, res) => { }); 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 => ({ id: c.getInstanceId(), name: c.name, diff --git a/server/routes/status.js b/server/routes/status.js new file mode 100644 index 0000000..53d0ce5 --- /dev/null +++ b/server/routes/status.js @@ -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; diff --git a/server/services/WebhookStatus.js b/server/services/WebhookStatus.js new file mode 100644 index 0000000..f13f556 --- /dev/null +++ b/server/services/WebhookStatus.js @@ -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} 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 +}; diff --git a/tests/integration/dashboard.test.js b/tests/integration/dashboard.test.js index 3f8fb29..bf44c5e 100644 --- a/tests/integration/dashboard.test.js +++ b/tests/integration/dashboard.test.js @@ -435,7 +435,7 @@ describe('GET /api/dashboard/user-downloads', () => { const dl = res.body.downloads.find(d => d.type === 'series'); if (dl) { 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 () => { 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); }); @@ -578,7 +578,7 @@ describe('GET /api/dashboard/status', () => { nock(RADARR_BASE).get('/api/v3/notification').reply(200, []); const res = await request(app) - .get('/api/dashboard/status') + .get('/api/status/status') .set('Cookie', cookies); expect(res.status).toBe(403); 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, []); const res = await request(app) - .get('/api/dashboard/status') + .get('/api/status/status') .set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.server).toBeDefined(); @@ -601,7 +601,6 @@ describe('GET /api/dashboard/status', () => { expect(res.body.cache).toBeDefined(); expect(res.body.polling).toBeDefined(); expect(res.body.webhooks).toBeDefined(); - expect(res.body.clients).toBeDefined(); }); 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'); const res = await request(app) - .get('/api/dashboard/status') + .get('/api/status/status') .set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.webhooks.sonarr).toBeNull(); @@ -629,7 +628,7 @@ describe('GET /api/dashboard/status', () => { nock(RADARR_BASE).get('/api/v3/notification').reply(200, []); const res = await request(app) - .get('/api/dashboard/status') + .get('/api/status/status') .set('Cookie', cookies); expect(res.status).toBe(200); 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 // ---------------------------------------------------------------------------