Files
dashboard/backend/src/services/serviceMonitor.service.ts
Matthias Hochmeister 215528a521 update
2026-03-16 14:41:08 +01:00

244 lines
7.1 KiB
TypeScript

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<MonitoredService[]> {
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<MonitoredService> {
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<Pick<MonitoredService, 'name' | 'url' | 'is_active'>>): Promise<MonitoredService> {
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<boolean> {
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<string, string>): Promise<PingResult> {
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<PingResult[]> {
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<string, string> | 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<Array<{ id: number; service_id: string; status: string; response_time_ms: number | null; checked_at: string }>> {
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<void> {
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<StatusSummary> {
// 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<string, string> }> {
const internal: Array<{ name: string; url: string; pingUrl: string; headers?: Record<string, string> }> = [];
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();