import axios from 'axios'; import httpClient from '../config/httpClient'; import pool from '../config/database'; import environment from '../config/environment'; export interface MonitoredService { id: string; name: string; url: string; type: 'internal' | 'custom'; is_active: boolean; created_at: string; updated_at: string; } export interface PingResult { name: string; url: string; status: 'up' | 'down'; latencyMs: number; error?: string; checked_at: string; } export interface StatusSummary { up: number; total: number; } class ServiceMonitorService { async getAllServices(): Promise { const result = await pool.query( 'SELECT * FROM monitored_services ORDER BY created_at' ); return result.rows; } async createService(name: string, url: string, type: string = 'custom'): Promise { const result = await pool.query( `INSERT INTO monitored_services (name, url, type) VALUES ($1, $2, $3) RETURNING *`, [name, url, type] ); return result.rows[0]; } async updateService(id: string, data: Partial>): Promise { const fields: string[] = []; const values: unknown[] = []; let idx = 1; if (data.name !== undefined) { fields.push(`name = $${idx++}`); values.push(data.name); } if (data.url !== undefined) { fields.push(`url = $${idx++}`); values.push(data.url); } if (data.is_active !== undefined) { fields.push(`is_active = $${idx++}`); values.push(data.is_active); } if (fields.length === 0) { throw new Error('No fields to update'); } values.push(id); const result = await pool.query( `UPDATE monitored_services SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, values ); if (result.rowCount === 0) { throw new Error('Service not found'); } return result.rows[0]; } async deleteService(id: string): Promise { const result = await pool.query( `DELETE FROM monitored_services WHERE id = $1 AND type = 'custom'`, [id] ); return (result.rowCount ?? 0) > 0; } async pingService(url: string, headers?: Record): Promise { const start = Date.now(); try { await httpClient.get(url, { timeout: 5000, headers }); return { name: '', url, status: 'up', latencyMs: Date.now() - start, checked_at: new Date().toISOString(), }; } catch (error) { // Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints // For API endpoints requiring auth, a 401/403 still means the service is up if (axios.isAxiosError(error) && error.response) { const status = error.response.status; // If we got a response, the service is running (auth errors = service is up) if (headers && (status === 401 || status === 403)) { return { name: '', url, status: 'up', latencyMs: Date.now() - start, checked_at: new Date().toISOString(), }; } } return { name: '', url, status: 'down', latencyMs: Date.now() - start, checked_at: new Date().toISOString(), error: axios.isAxiosError(error) ? `${error.code ?? 'ERROR'}: ${error.message}` : String(error), }; } } async pingAll(): Promise { const services = await this.getAllServices(); const activeServices = services.filter((s) => s.is_active); const internalServices = this.getInternalServices(); const allTargets = [ ...activeServices.map((s) => ({ name: s.name, url: s.url, pingUrl: s.url, headers: undefined as Record | undefined })), ...internalServices, ]; const results = await Promise.all( allTargets.map(async (target) => { const result = await this.pingService(target.pingUrl, target.headers); result.name = target.name; result.url = target.url; return result; }) ); // Store ping results in history (fire-and-forget) this.storePingResults(results).catch(() => {}); return results; } async getPingHistory(serviceId: string): Promise> { const result = await pool.query( 'SELECT * FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20', [serviceId] ); return result.rows; } private async storePingResults(results: PingResult[]): Promise { for (const r of results) { const serviceId = r.name || r.url; await pool.query( 'INSERT INTO service_ping_history (service_id, status, response_time_ms, checked_at) VALUES ($1, $2, $3, $4)', [serviceId, r.status, r.latencyMs, r.checked_at] ); // Keep only last 20 per service await pool.query( `DELETE FROM service_ping_history WHERE service_id = $1 AND id NOT IN ( SELECT id FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20 )`, [serviceId] ); } } async getStatusSummary(): Promise { // Read latest stored ping results instead of triggering a new ping cycle try { const { rows } = await pool.query(` SELECT DISTINCT ON (service_id) service_id, status FROM service_ping_history ORDER BY service_id, checked_at DESC `); if (rows.length > 0) { return { up: rows.filter((r: any) => r.status === 'up').length, total: rows.length, }; } } catch { // Fall through to live ping if no history } // Fallback: no history yet — do a live ping const results = await this.pingAll(); return { up: results.filter((r) => r.status === 'up').length, total: results.length, }; } private getInternalServices(): Array<{ name: string; url: string; pingUrl: string; headers?: Record }> { const internal: Array<{ name: string; url: string; pingUrl: string; headers?: Record }> = []; const { bookstack, vikunja, nextcloudUrl } = environment; if (nextcloudUrl) { internal.push({ name: 'Nextcloud', url: nextcloudUrl, pingUrl: `${nextcloudUrl}/status.php`, }); } if (bookstack.url) { internal.push({ name: 'BookStack', url: bookstack.url, pingUrl: `${bookstack.url}/api/pages?count=1`, headers: { 'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`, }, }); } if (vikunja.url) { internal.push({ name: 'Vikunja', url: vikunja.url, pingUrl: `${vikunja.url}/api/v1/tasks/all?per_page=1`, headers: { 'Authorization': `Bearer ${vikunja.apiToken}`, }, }); } return internal; } } export default new ServiceMonitorService();