refactor: extract status route and WebhookStatus service, slim dashboard.js
All checks were successful
All checks were successful
- 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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
71
server/routes/status.js
Normal 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;
|
||||||
55
server/services/WebhookStatus.js
Normal file
55
server/services/WebhookStatus.js
Normal 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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user