import { Router, Request, Response } from 'express'; import { getDatabase } from '../db'; import config, { isServiceConfigured } from '../config'; import logger from '../utils/logger'; import { getAppVersion } from '../utils/version'; import { getScheduler } from '../scheduler'; import * as scanHistoryRepo from '../db/repositories/scanHistoryRepo'; import { getPlexService, getRadarrService, getSonarrService, getTautulliService, getTracearrService, getOverseerrService } from '../services/init'; import { getConfiguredServerType, getMediaServerLabel, type MediaServerType } from '../services/mediaServer'; import { getLastSyncCompletedAt, getLastSyncFinishedAt, getLastSyncSuccess, } from '../services/syncCoordinator'; // APP_VERSION at Docker build time, package.json otherwise. Shared with the // telemetry heartbeat so both report the same string. const appVersion = getAppVersion(); const router = Router(); interface HealthStatus { status: 'healthy' | 'unhealthy' | 'degraded'; timestamp: string; version: string; uptime: number; database: { status: 'connected' | 'disconnected'; path: string; }; services: { plex: { configured: boolean }; tautulli: { configured: boolean }; tracearr: { configured: boolean }; sonarr: { configured: boolean }; radarr: { configured: boolean }; overseerr: { configured: boolean }; discord: { configured: boolean }; }; } router.get('/', (_req: Request, res: Response) => { let dbStatus: 'connected' | 'disconnected' = 'disconnected'; try { const db = getDatabase(); // Test database connection db.prepare('SELECT 1').get(); dbStatus = 'connected'; } catch (error) { logger.error('Database health check failed:', error); dbStatus = 'disconnected'; } const health: HealthStatus = { status: dbStatus === 'connected' ? 'healthy' : 'unhealthy', timestamp: new Date().toISOString(), version: appVersion, uptime: process.uptime(), database: { status: dbStatus, path: config.dbPath, }, services: { plex: { configured: isServiceConfigured('plex') }, tautulli: { configured: isServiceConfigured('tautulli') }, tracearr: { configured: isServiceConfigured('tracearr') }, sonarr: { configured: isServiceConfigured('sonarr') }, radarr: { configured: isServiceConfigured('radarr') }, overseerr: { configured: isServiceConfigured('overseerr') }, discord: { configured: isServiceConfigured('discord') }, }, }; // Determine overall status const anyServiceConfigured = Object.values(health.services).some((s) => s.configured); if (dbStatus === 'connected' && anyServiceConfigured) { health.status = 'healthy'; } else if (dbStatus === 'connected') { health.status = 'degraded'; } else { health.status = 'unhealthy'; } const statusCode = health.status === 'unhealthy' ? 503 : 200; res.status(statusCode).json(health); }); // Simple ping endpoint for basic connectivity checks router.get('/ping', (_req: Request, res: Response) => { res.json({ pong: true, timestamp: Date.now() }); }); // Version endpoint router.get('/version', (_req: Request, res: Response) => { res.json({ version: appVersion }); }); // Detailed readiness check router.get('/ready', (_req: Request, res: Response) => { try { const db = getDatabase(); db.prepare('SELECT 1').get(); res.json({ ready: true }); } catch (error) { logger.error('Readiness check failed:', error); res.status(503).json({ ready: false, error: 'Database not ready' }); } }); // Liveness check router.get('/live', (_req: Request, res: Response) => { res.json({ live: true, timestamp: Date.now() }); }); // ============================================================================ // Aggregated Health Status for Dashboard // ============================================================================ interface ServiceHealthStatus { service: string; configured: boolean; connected: boolean; responseTimeMs?: number; error?: string; lastChecked: string; } interface SchedulerStatus { isRunning: boolean; lastScan: string | null; nextRun: string | null; scanSchedule: string; lastSync: string | null; // last successful Plex sync lastSyncAt: string | null; // last sync attempt finish (success OR failure) lastSyncSuccess: boolean | null; // whether that finish was a success nextSync: string | null; syncSchedule: string; } interface SystemHealthResponse { services: ServiceHealthStatus[]; scheduler: SchedulerStatus; overall: 'healthy' | 'degraded' | 'unhealthy'; /** * Which backend the 'plex' service entry actually refers to. The entry keeps * its historical key so existing clients keep resolving it; this field lets * the UI label it correctly for Jellyfin/Emby installs. */ mediaServerType: MediaServerType; mediaServerLabel: string; } async function checkService( name: string, service: { testConnection: () => Promise } | null ): Promise { const lastChecked = new Date().toISOString(); if (!service) { return { service: name, configured: false, connected: false, lastChecked }; } const startTime = Date.now(); try { // Race the connection test against a 5-second timeout const connected = await Promise.race([ service.testConnection(), new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout')), 5000) ), ]); return { service: name, configured: true, connected, responseTimeMs: Date.now() - startTime, lastChecked, }; } catch (error) { return { service: name, configured: true, connected: false, error: error instanceof Error ? error.message : 'Unknown error', responseTimeMs: Date.now() - startTime, lastChecked, }; } } function determineOverallHealth(services: ServiceHealthStatus[]): 'healthy' | 'degraded' | 'unhealthy' { const configuredServices = services.filter((s) => s.configured); if (configuredServices.length === 0) return 'unhealthy'; const connectedCount = configuredServices.filter((s) => s.connected).length; if (connectedCount === configuredServices.length) return 'healthy'; if (connectedCount > 0) return 'degraded'; return 'unhealthy'; } // Aggregated health status for dashboard router.get('/status', async (_req: Request, res: Response) => { try { const serverType = getConfiguredServerType(); // Run all service checks in parallel const serviceChecks = await Promise.allSettled([ checkService('plex', getPlexService()), checkService('radarr', getRadarrService()), checkService('sonarr', getSonarrService()), checkService('tautulli', getTautulliService()), checkService('tracearr', getTracearrService()), checkService('overseerr', getOverseerrService()), ]); const serviceNames = ['plex', 'radarr', 'sonarr', 'tautulli', 'tracearr', 'overseerr']; const services: ServiceHealthStatus[] = serviceChecks.map((result, index) => { if (result.status === 'fulfilled') { return result.value; } return { service: serviceNames[index] || 'unknown', configured: false, connected: false, error: result.reason instanceof Error ? result.reason.message : 'Check failed', lastChecked: new Date().toISOString(), }; }); // Get scheduler info const scheduler = getScheduler(); const scanJobStatus = scheduler.getJobStatus('scanLibraries'); const syncJobStatus = scheduler.getJobStatus('syncPlexLibrary'); const schedulerConfig = scheduler.getConfig(); // Get last scan from history const latestScan = scanHistoryRepo.getLatest(); const lastSyncCompletedAt = getLastSyncCompletedAt(); const lastSyncFinishedAt = getLastSyncFinishedAt(); const lastSyncSuccess = getLastSyncSuccess(); const response: SystemHealthResponse = { services, scheduler: { isRunning: scheduler.isSchedulerRunning(), lastScan: latestScan?.completed_at || latestScan?.started_at || null, nextRun: scanJobStatus?.nextRun?.toISOString() || null, scanSchedule: schedulerConfig.schedules.scanLibraries, lastSync: lastSyncCompletedAt?.toISOString() || null, lastSyncAt: lastSyncFinishedAt?.toISOString() || null, lastSyncSuccess, nextSync: syncJobStatus?.nextRun?.toISOString() || null, syncSchedule: schedulerConfig.schedules.syncPlexLibrary, }, overall: determineOverallHealth(services), mediaServerType: serverType, mediaServerLabel: getMediaServerLabel(serverType), }; res.json({ success: true, data: response }); } catch (error) { logger.error('Health status check failed:', error); res.status(500).json({ success: false, error: 'Failed to check system health', }); } }); export default router;