244 lines
7.1 KiB
TypeScript
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();
|