mirror of
https://github.com/dcarrillo/atalaya.git
synced 2026-04-18 10:34:06 +00:00
181
src/api/status.test.ts
Normal file
181
src/api/status.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { getStatusApiData } from './status.js';
|
||||
import type { Config } from '../config/types.js';
|
||||
|
||||
function mockD1Database(results: { states: unknown[]; hourly: unknown[]; recent: unknown[] }) {
|
||||
return {
|
||||
prepare: vi.fn((sql: string) => ({
|
||||
bind: vi.fn(() => ({
|
||||
all: vi.fn(async () => {
|
||||
if (sql.includes('monitor_state')) {
|
||||
return { results: results.states };
|
||||
}
|
||||
|
||||
if (sql.includes('check_results_hourly')) {
|
||||
return { results: results.hourly };
|
||||
}
|
||||
|
||||
if (sql.includes('check_results')) {
|
||||
return { results: results.recent };
|
||||
}
|
||||
|
||||
return { results: [] };
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
} as unknown as D1Database;
|
||||
}
|
||||
|
||||
const testConfig: Config = {
|
||||
settings: {
|
||||
title: 'Test Status Page',
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 10000,
|
||||
defaultFailureThreshold: 3,
|
||||
},
|
||||
monitors: [],
|
||||
alerts: [],
|
||||
};
|
||||
|
||||
describe('getStatusApiData', () => {
|
||||
it('returns empty monitors when DB has no data', async () => {
|
||||
const db = mockD1Database({ states: [], hourly: [], recent: [] });
|
||||
const result = await getStatusApiData(db, testConfig);
|
||||
|
||||
expect(result.monitors).toEqual([]);
|
||||
expect(result.summary).toEqual({ total: 0, operational: 0, down: 0 });
|
||||
expect(typeof result.lastUpdated).toBe('number');
|
||||
expect(result.title).toBe('Test Status Page');
|
||||
});
|
||||
|
||||
it('returns monitor with correct status and uptime', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hourTimestamp = now - 3600;
|
||||
|
||||
const db = mockD1Database({
|
||||
states: [{ monitor_name: 'test-monitor', current_status: 'up', last_checked: now }],
|
||||
hourly: [
|
||||
{
|
||||
monitor_name: 'test-monitor',
|
||||
hour_timestamp: hourTimestamp,
|
||||
total_checks: 60,
|
||||
successful_checks: 58,
|
||||
},
|
||||
],
|
||||
recent: [
|
||||
{
|
||||
monitor_name: 'test-monitor',
|
||||
checked_at: now - 60,
|
||||
status: 'up',
|
||||
response_time_ms: 120,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getStatusApiData(db, testConfig);
|
||||
|
||||
expect(result.monitors).toHaveLength(1);
|
||||
expect(result.monitors[0].name).toBe('test-monitor');
|
||||
expect(result.monitors[0].status).toBe('up');
|
||||
expect(result.monitors[0].lastChecked).toBe(now);
|
||||
expect(result.monitors[0].dailyHistory).toHaveLength(90);
|
||||
expect(result.monitors[0].recentChecks).toHaveLength(1);
|
||||
expect(result.monitors[0].recentChecks[0]).toEqual({
|
||||
timestamp: now - 60,
|
||||
status: 'up',
|
||||
responseTimeMs: 120,
|
||||
});
|
||||
expect(result.summary).toEqual({ total: 1, operational: 1, down: 0 });
|
||||
expect(result.title).toBe('Test Status Page');
|
||||
});
|
||||
|
||||
it('computes summary counts correctly with mixed statuses', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const db = mockD1Database({
|
||||
states: [
|
||||
{ monitor_name: 'up-monitor', current_status: 'up', last_checked: now },
|
||||
{ monitor_name: 'down-monitor', current_status: 'down', last_checked: now },
|
||||
{ monitor_name: 'another-up', current_status: 'up', last_checked: now },
|
||||
],
|
||||
hourly: [],
|
||||
recent: [],
|
||||
});
|
||||
|
||||
const result = await getStatusApiData(db, testConfig);
|
||||
|
||||
expect(result.summary).toEqual({ total: 3, operational: 2, down: 1 });
|
||||
expect(result.title).toBe('Test Status Page');
|
||||
});
|
||||
|
||||
it('does not count unknown status monitors as down', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const db = mockD1Database({
|
||||
states: [
|
||||
{ monitor_name: 'up-monitor', current_status: 'up', last_checked: now },
|
||||
{ monitor_name: 'unknown-monitor', current_status: 'unknown', last_checked: now },
|
||||
],
|
||||
hourly: [],
|
||||
recent: [],
|
||||
});
|
||||
|
||||
const result = await getStatusApiData(db, testConfig);
|
||||
|
||||
expect(result.summary).toEqual({ total: 2, operational: 1, down: 0 });
|
||||
expect(result.title).toBe('Test Status Page');
|
||||
});
|
||||
|
||||
it('computes daily uptime percentage from hourly data', async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayStartUnix = Math.floor(todayStart.getTime() / 1000);
|
||||
|
||||
const db = mockD1Database({
|
||||
states: [
|
||||
{
|
||||
monitor_name: 'test',
|
||||
current_status: 'up',
|
||||
last_checked: Math.floor(now.getTime() / 1000),
|
||||
},
|
||||
],
|
||||
hourly: [
|
||||
{
|
||||
monitor_name: 'test',
|
||||
hour_timestamp: todayStartUnix,
|
||||
total_checks: 60,
|
||||
successful_checks: 57,
|
||||
},
|
||||
{
|
||||
monitor_name: 'test',
|
||||
hour_timestamp: todayStartUnix + 3600,
|
||||
total_checks: 60,
|
||||
successful_checks: 60,
|
||||
},
|
||||
],
|
||||
recent: [],
|
||||
});
|
||||
|
||||
const result = await getStatusApiData(db, testConfig);
|
||||
const today = result.monitors[0].dailyHistory.at(-1);
|
||||
|
||||
expect(today).toBeDefined();
|
||||
expect(today!.uptimePercent).toBeCloseTo((117 / 120) * 100, 1);
|
||||
expect(result.title).toBe('Test Status Page');
|
||||
});
|
||||
|
||||
it('uses default title when no config is provided', async () => {
|
||||
const db = mockD1Database({ states: [], hourly: [], recent: [] });
|
||||
const result = await getStatusApiData(db);
|
||||
|
||||
expect(result.title).toBe('Atalaya Uptime Monitor');
|
||||
});
|
||||
|
||||
it('uses default title when config has no settings', async () => {
|
||||
const db = mockD1Database({ states: [], hourly: [], recent: [] });
|
||||
const result = await getStatusApiData(db, { settings: {} } as Config);
|
||||
|
||||
expect(result.title).toBe('Atalaya Uptime Monitor');
|
||||
});
|
||||
});
|
||||
145
src/api/status.ts
Normal file
145
src/api/status.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
StatusApiResponse,
|
||||
ApiMonitorStatus,
|
||||
ApiDayStatus,
|
||||
ApiRecentCheck,
|
||||
} from '../types.js';
|
||||
import type { Config } from '../config/types.js';
|
||||
|
||||
type HourlyRow = {
|
||||
monitor_name: string;
|
||||
hour_timestamp: number;
|
||||
total_checks: number;
|
||||
successful_checks: number;
|
||||
};
|
||||
|
||||
type CheckResultRow = {
|
||||
monitor_name: string;
|
||||
checked_at: number;
|
||||
status: string;
|
||||
response_time_ms: number | undefined;
|
||||
};
|
||||
|
||||
type MonitorStateRow = {
|
||||
monitor_name: string;
|
||||
current_status: string;
|
||||
last_checked: number;
|
||||
};
|
||||
|
||||
export async function getStatusApiData(
|
||||
database: D1Database,
|
||||
config?: Config
|
||||
): Promise<StatusApiResponse> {
|
||||
const states = await database
|
||||
.prepare('SELECT monitor_name, current_status, last_checked FROM monitor_state WHERE 1=?')
|
||||
.bind(1)
|
||||
.all<MonitorStateRow>();
|
||||
|
||||
const ninetyDaysAgo = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
|
||||
const hourlyData = await database
|
||||
.prepare(
|
||||
'SELECT monitor_name, hour_timestamp, total_checks, successful_checks FROM check_results_hourly WHERE hour_timestamp >= ?'
|
||||
)
|
||||
.bind(ninetyDaysAgo)
|
||||
.all<HourlyRow>();
|
||||
|
||||
const hourlyByMonitor = new Map<string, HourlyRow[]>();
|
||||
for (const row of hourlyData.results ?? []) {
|
||||
const existing = hourlyByMonitor.get(row.monitor_name) ?? [];
|
||||
existing.push(row);
|
||||
hourlyByMonitor.set(row.monitor_name, existing);
|
||||
}
|
||||
|
||||
const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - 24 * 60 * 60;
|
||||
const recentChecks = await database
|
||||
.prepare(
|
||||
'SELECT monitor_name, checked_at, status, response_time_ms FROM check_results WHERE checked_at >= ? ORDER BY monitor_name, checked_at'
|
||||
)
|
||||
.bind(twentyFourHoursAgo)
|
||||
.all<CheckResultRow>();
|
||||
|
||||
const checksByMonitor = new Map<string, CheckResultRow[]>();
|
||||
for (const row of recentChecks.results ?? []) {
|
||||
const existing = checksByMonitor.get(row.monitor_name) ?? [];
|
||||
existing.push(row);
|
||||
checksByMonitor.set(row.monitor_name, existing);
|
||||
}
|
||||
|
||||
const monitors: ApiMonitorStatus[] = (states.results ?? []).map(state => {
|
||||
const hourly = hourlyByMonitor.get(state.monitor_name) ?? [];
|
||||
const dailyHistory = computeDailyHistory(hourly);
|
||||
const uptimePercent = computeOverallUptime(hourly);
|
||||
|
||||
const status: 'up' | 'down' | 'unknown' =
|
||||
state.current_status === 'up' || state.current_status === 'down'
|
||||
? state.current_status
|
||||
: 'unknown';
|
||||
|
||||
const rawChecks = checksByMonitor.get(state.monitor_name) ?? [];
|
||||
const apiRecentChecks: ApiRecentCheck[] = rawChecks.map(c => ({
|
||||
timestamp: c.checked_at,
|
||||
status: c.status === 'up' ? ('up' as const) : ('down' as const),
|
||||
responseTimeMs: c.response_time_ms ?? 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
name: state.monitor_name,
|
||||
status,
|
||||
lastChecked: state.last_checked ?? null,
|
||||
uptimePercent,
|
||||
dailyHistory,
|
||||
recentChecks: apiRecentChecks,
|
||||
};
|
||||
});
|
||||
|
||||
const operational = monitors.filter(m => m.status === 'up').length;
|
||||
const down = monitors.filter(m => m.status === 'down').length;
|
||||
|
||||
return {
|
||||
monitors,
|
||||
summary: {
|
||||
total: monitors.length,
|
||||
operational,
|
||||
down,
|
||||
},
|
||||
lastUpdated: Math.floor(Date.now() / 1000),
|
||||
title: config?.settings.title ?? 'Atalaya Uptime Monitor',
|
||||
};
|
||||
}
|
||||
|
||||
function computeDailyHistory(hourly: HourlyRow[]): ApiDayStatus[] {
|
||||
const now = new Date();
|
||||
const days: ApiDayStatus[] = Array.from({ length: 90 }, (_, i) => {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - (89 - i));
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const dayStart = Math.floor(date.getTime() / 1000);
|
||||
const dayEnd = dayStart + 24 * 60 * 60;
|
||||
|
||||
const dayHours = hourly.filter(h => h.hour_timestamp >= dayStart && h.hour_timestamp < dayEnd);
|
||||
|
||||
let uptimePercent: number | undefined;
|
||||
if (dayHours.length > 0) {
|
||||
const totalChecks = dayHours.reduce((sum, h) => sum + h.total_checks, 0);
|
||||
const successfulChecks = dayHours.reduce((sum, h) => sum + h.successful_checks, 0);
|
||||
uptimePercent = totalChecks > 0 ? (successfulChecks / totalChecks) * 100 : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
uptimePercent,
|
||||
};
|
||||
});
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function computeOverallUptime(hourly: HourlyRow[]): number {
|
||||
if (hourly.length === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
const totalChecks = hourly.reduce((sum, h) => sum + h.total_checks, 0);
|
||||
const successfulChecks = hourly.reduce((sum, h) => sum + h.successful_checks, 0);
|
||||
return totalChecks > 0 ? (successfulChecks / totalChecks) * 100 : 100;
|
||||
}
|
||||
Reference in New Issue
Block a user