Files
sofarr/server/routes/status.js
T
gronod 6fa9c79a7d
Build and Push Docker Image / build (push) Successful in 59s
Docs Check / Markdown lint (push) Failing after 1m45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m7s
CI / Security audit (push) Successful in 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 2m55s
CI / Swagger Validation & Coverage (push) Successful in 3m19s
CI / Tests & coverage (push) Successful in 3m29s
fix: rTorrent null-safety, configurable SAB_HISTORY_LIMIT, lastError visibility (#68)
- RTorrentClient: guard d.multicall2 returning non-array, per-row try/catch,
  explicit Number()/String() coercions, _extractArrInfo null-safe
- RTorrentClient.getClientStatus: coerce rates through Number.isFinite
- SABnzbdClient: history limit now reads SAB_HISTORY_LIMIT env var (default 10)
- DownloadClient: added _recordLastError, _clearLastError, getLastError on base
- All four clients call _recordLastError on failure, _clearLastError on success
- DownloadClientRegistry.getAllClientStatuses: includes lastError in result
- GET /api/status/status: exposes downloadClients[] array with per-client lastError
- Tests: RTorrentClient null-safety + lastError, SABnzbd history limit + lastError,
  downloadClients.test expectation updated for new lastError field
2026-05-28 16:22:11 +01:00

186 lines
7.1 KiB
JavaScript

// 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, getOmbiInstances } = require('../utils/config');
const { getGlobalWebhookMetrics } = require('../utils/cache');
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
const downloadClientRegistry = require('../utils/downloadClients');
/**
* @openapi
* /api/status/status:
* get:
* tags: [Status]
* summary: Get server status (admin-only)
* description: |
* Admin-only endpoint returning server metrics, cache statistics, polling information,
* and webhook metrics. Used by the admin status panel to monitor sofarr health.
*
* **Authentication:** Requires valid `emby_user` cookie (admin only).
*
* **Response Structure:**
* - `server`: Uptime, Node version, memory usage
* - `polling`: Polling enabled status, interval, last poll timings
* - `cache`: Cache statistics (item count, sizes, TTLs)
* - `webhooks`: Webhook configuration and metrics for Sonarr/Radarr
*
* **Webhook Metrics:**
* - `configured`: Whether webhook is configured in Sonarr/Radarr
* - `eventsReceived`: Total webhook events received
* - `lastWebhookTimestamp`: Last webhook event time
* - `pollsSkipped`: Number of poll cycles skipped due to recent webhook activity
*
* **x-integration-notes:** This endpoint is used by the admin status panel to display:
* - Server health and resource usage
* - Polling performance and timing
* - Cache hit rates and sizes
* - Webhook activity and smart polling effectiveness
* security:
* - CookieAuth: []
* responses:
* '200':
* description: Status data
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatusResponse'
* example:
* server:
* uptimeSeconds: 3600
* nodeVersion: "v22.0.0"
* memoryUsageMB: 128.5
* heapUsedMB: 64.2
* heapTotalMB: 128.0
* polling:
* enabled: true
* intervalMs: 5000
* lastPoll:
* sabnzbdQueue: 150
* sonarrQueue: 200
* cache:
* "poll:sab-queue":
* size: 2456
* items: 1
* ttlRemaining: 12000
* webhooks:
* sonarr:
* configured: true
* eventsReceived: 42
* lastWebhookTimestamp: "2026-05-21T10:00:00.000Z"
* pollsSkipped: 15
* radarr:
* configured: true
* eventsReceived: 38
* lastWebhookTimestamp: "2026-05-21T09:55:00.000Z"
* pollsSkipped: 12
* '403':
* description: Admin access required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Admin access required"
* '500':
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* curl -X GET http://localhost:3001/api/status/status \
* -b cookies.txt
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const response = await fetch('http://localhost:3001/api/status/status', {
* method: 'GET',
* credentials: 'include'
* });
* const data = await response.json();
* console.log('Uptime:', data.server.uptimeSeconds);
*/
router.get('/', 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 ombiInstances = getOmbiInstances();
const sonarrWebhookConfigured = sonarrInstances.length > 0
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
: false;
const radarrWebhookConfigured = radarrInstances.length > 0
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
: false;
const ombiWebhookConfigured = ombiInstances.length > 0
? await checkOmbiWebhookConfigured(ombiInstances[0])
: false;
// Find Sonarr, Radarr, and Ombi metrics from instances
const sonarrMetrics = {};
const radarrMetrics = {};
const ombiMetrics = {};
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
if (url.includes('sonarr')) {
sonarrMetrics[url] = metrics;
} else if (url.includes('radarr')) {
radarrMetrics[url] = metrics;
} else if (url.includes('ombi') || (ombiInstances.length > 0 && url === ombiInstances[0].url)) {
ombiMetrics[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),
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
},
// Per-download-client health summary including any lastError captured
// since the last successful call. Lets the admin status panel surface
// transient failures (auth expiry, RPC blips, etc.) without log scraping.
downloadClients: downloadClientRegistry.getAllClients().map(c => ({
instanceId: c.getInstanceId(),
instanceName: c.name,
clientType: c.getClientType(),
lastError: typeof c.getLastError === 'function' ? c.getLastError() : null
}))
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
}
});
module.exports = router;