feat(webhooks): display webhook statistics (events received, polls skipped, last event) in status panel
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Successful in 1m24s

This commit is contained in:
2026-05-19 19:18:29 +01:00
parent 015e07ae7a
commit d06e24dbb6
3 changed files with 143 additions and 14 deletions

View File

@@ -459,3 +459,41 @@ body {
.trigger-value.inactive { .trigger-value.inactive {
color: #999; 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;
}

View File

@@ -11,8 +11,9 @@ function App() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false); const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
const [sonarrWebhook, setSonarrWebhook] = 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 } }); 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); const [webhookLoading, setWebhookLoading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -72,43 +73,82 @@ function App() {
return new Date(dateString).toLocaleString(); 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 () => { const fetchWebhookStatus = async () => {
try { try {
// Fetch metrics in parallel with notification status
const metricsPromise = fetchWebhookMetrics();
// Fetch Sonarr notifications // Fetch Sonarr notifications
let sonarrEnabled = false;
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try { try {
const sonarrResponse = await axios.get('/api/sonarr/notifications'); const sonarrResponse = await axios.get('/api/sonarr/notifications');
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr'); const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
setSonarrWebhook({ sonarrEnabled = !!sonarrSofarr;
enabled: !!sonarrSofarr, if (sonarrSofarr) {
triggers: sonarrSofarr ? { sonarrTriggers = {
onGrab: sonarrSofarr.onGrab, onGrab: sonarrSofarr.onGrab,
onDownload: sonarrSofarr.onDownload, onDownload: sonarrSofarr.onDownload,
onImport: sonarrSofarr.onImport, onImport: sonarrSofarr.onImport,
onUpgrade: sonarrSofarr.onUpgrade onUpgrade: sonarrSofarr.onUpgrade
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } };
}); }
} catch (err) { } catch (err) {
// Sonarr not configured or not accessible // Sonarr not configured or not accessible
setSonarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
} }
// Fetch Radarr notifications // Fetch Radarr notifications
let radarrEnabled = false;
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try { try {
const radarrResponse = await axios.get('/api/radarr/notifications'); const radarrResponse = await axios.get('/api/radarr/notifications');
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr'); const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
setRadarrWebhook({ radarrEnabled = !!radarrSofarr;
enabled: !!radarrSofarr, if (radarrSofarr) {
triggers: radarrSofarr ? { radarrTriggers = {
onGrab: radarrSofarr.onGrab, onGrab: radarrSofarr.onGrab,
onDownload: radarrSofarr.onDownload, onDownload: radarrSofarr.onDownload,
onImport: radarrSofarr.onImport, onImport: radarrSofarr.onImport,
onUpgrade: radarrSofarr.onUpgrade onUpgrade: radarrSofarr.onUpgrade
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } };
}); }
} catch (err) { } catch (err) {
// Radarr not configured or not accessible // 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) { } catch (err) {
console.error('Failed to fetch webhook status:', err); console.error('Failed to fetch webhook status:', err);
} }
@@ -147,6 +187,7 @@ function App() {
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr'); const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
if (sonarrSofarr) { if (sonarrSofarr) {
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id }); await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
await fetchWebhookStatus();
alert('Sonarr webhook test sent successfully!'); alert('Sonarr webhook test sent successfully!');
} else { } else {
alert('Sofarr webhook not configured for Sonarr.'); alert('Sofarr webhook not configured for Sonarr.');
@@ -166,6 +207,7 @@ function App() {
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr'); const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
if (radarrSofarr) { if (radarrSofarr) {
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id }); await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
await fetchWebhookStatus();
alert('Radarr webhook test sent successfully!'); alert('Radarr webhook test sent successfully!');
} else { } else {
alert('Sofarr webhook not configured for Radarr.'); alert('Sofarr webhook not configured for Radarr.');
@@ -342,6 +384,25 @@ function App() {
</div> </div>
</div> </div>
)} )}
{sonarrWebhook.stats && (
<div className="webhook-stats">
<div className="webhook-stats-title">Statistics</div>
<div className="webhook-stats-grid">
<div className="webhook-stat">
<span className="webhook-stat-label">Events Received</span>
<span className="webhook-stat-value">{sonarrWebhook.stats.eventsReceived ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Polls Skipped</span>
<span className="webhook-stat-value">{sonarrWebhook.stats.pollsSkipped ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Last Event</span>
<span className="webhook-stat-value">{formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp)}</span>
</div>
</div>
</div>
)}
</div> </div>
<div className="webhook-instance"> <div className="webhook-instance">
<h3>Radarr</h3> <h3>Radarr</h3>
@@ -388,6 +449,25 @@ function App() {
</div> </div>
</div> </div>
)} )}
{radarrWebhook.stats && (
<div className="webhook-stats">
<div className="webhook-stats-title">Statistics</div>
<div className="webhook-stats-grid">
<div className="webhook-stat">
<span className="webhook-stat-label">Events Received</span>
<span className="webhook-stat-value">{radarrWebhook.stats.eventsReceived ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Polls Skipped</span>
<span className="webhook-stat-value">{radarrWebhook.stats.pollsSkipped ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Last Event</span>
<span className="webhook-stat-value">{formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp)}</span>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -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 // 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.