Kick off (#1)

* Kick off
* Update LICENSE copyright
This commit is contained in:
2026-04-11 13:22:36 +02:00
committed by GitHub
parent 1f1e74c9f8
commit 3882a1941a
76 changed files with 17154 additions and 1 deletions

181
src/api/status.test.ts Normal file
View 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
View 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;
}