diff --git a/client/src/App.css b/client/src/App.css index 1831a5c..084372a 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -459,3 +459,41 @@ body { .trigger-value.inactive { color: #999; } + +.webhook-stats { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #e0e0e0; +} + +.webhook-stats-title { + color: #999; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + margin-bottom: 10px; +} + +.webhook-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.webhook-stat { + display: flex; + flex-direction: column; + gap: 3px; +} + +.webhook-stat-label { + color: #999; + font-size: 0.8rem; +} + +.webhook-stat-value { + color: #333; + font-size: 0.95rem; + font-weight: 500; +} diff --git a/client/src/App.jsx b/client/src/App.jsx index aeef72d..6b27a6d 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -11,8 +11,9 @@ function App() { const [error, setError] = useState(null); const [sessions, setSessions] = useState([]); const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false); - const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } }); - const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } }); + const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }); + const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }); + const [webhookMetrics, setWebhookMetrics] = useState(null); const [webhookLoading, setWebhookLoading] = useState(false); useEffect(() => { @@ -72,43 +73,82 @@ function App() { return new Date(dateString).toLocaleString(); }; + const formatTimeAgo = (timestamp) => { + if (!timestamp) return 'Never'; + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; + }; + + const fetchWebhookMetrics = async () => { + try { + const response = await axios.get('/api/dashboard/webhook-metrics'); + setWebhookMetrics(response.data); + return response.data; + } catch (err) { + // Not fatal — stats just won't display + return null; + } + }; + const fetchWebhookStatus = async () => { try { + // Fetch metrics in parallel with notification status + const metricsPromise = fetchWebhookMetrics(); + // Fetch Sonarr notifications + let sonarrEnabled = false; + let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; try { const sonarrResponse = await axios.get('/api/sonarr/notifications'); const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr'); - setSonarrWebhook({ - enabled: !!sonarrSofarr, - triggers: sonarrSofarr ? { + sonarrEnabled = !!sonarrSofarr; + if (sonarrSofarr) { + sonarrTriggers = { onGrab: sonarrSofarr.onGrab, onDownload: sonarrSofarr.onDownload, onImport: sonarrSofarr.onImport, onUpgrade: sonarrSofarr.onUpgrade - } : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } - }); + }; + } } catch (err) { // Sonarr not configured or not accessible - setSonarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } }); } // Fetch Radarr notifications + let radarrEnabled = false; + let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; try { const radarrResponse = await axios.get('/api/radarr/notifications'); const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr'); - setRadarrWebhook({ - enabled: !!radarrSofarr, - triggers: radarrSofarr ? { + radarrEnabled = !!radarrSofarr; + if (radarrSofarr) { + radarrTriggers = { onGrab: radarrSofarr.onGrab, onDownload: radarrSofarr.onDownload, onImport: radarrSofarr.onImport, onUpgrade: radarrSofarr.onUpgrade - } : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } - }); + }; + } } catch (err) { // Radarr not configured or not accessible - setRadarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } }); } + + const metrics = await metricsPromise; + + // Attach per-instance stats from global metrics. + // The instances object is keyed by instance URL; we pick the first + // sonarr/radarr entry by matching env-configured URLs. + const instanceEntries = metrics ? Object.entries(metrics.instances || {}) : []; + const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null; + const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null; + + setSonarrWebhook({ enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats }); + setRadarrWebhook({ enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats }); } catch (err) { console.error('Failed to fetch webhook status:', err); } @@ -147,6 +187,7 @@ function App() { const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr'); if (sonarrSofarr) { await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id }); + await fetchWebhookStatus(); alert('Sonarr webhook test sent successfully!'); } else { alert('Sofarr webhook not configured for Sonarr.'); @@ -166,6 +207,7 @@ function App() { const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr'); if (radarrSofarr) { await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id }); + await fetchWebhookStatus(); alert('Radarr webhook test sent successfully!'); } else { alert('Sofarr webhook not configured for Radarr.'); @@ -342,6 +384,25 @@ function App() { )} + {sonarrWebhook.stats && ( +
+
Statistics
+
+
+ Events Received + {sonarrWebhook.stats.eventsReceived ?? 0} +
+
+ Polls Skipped + {sonarrWebhook.stats.pollsSkipped ?? 0} +
+
+ Last Event + {formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp)} +
+
+
+ )}

Radarr

@@ -388,6 +449,25 @@ function App() {
)} + {radarrWebhook.stats && ( +
+
Statistics
+
+
+ Events Received + {radarrWebhook.stats.eventsReceived ?? 0} +
+
+ Polls Skipped + {radarrWebhook.stats.pollsSkipped ?? 0} +
+
+ Last Event + {formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp)} +
+
+
+ )} )} diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index c6b389e..e72fa29 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -803,6 +803,17 @@ router.get('/status', requireAuth, (req, res) => { } }); +// 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.