mirror of
https://github.com/dcarrillo/atalaya.git
synced 2026-04-18 02:24:05 +00:00
158
src/aggregation.test.ts
Normal file
158
src/aggregation.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { handleAggregation } from './aggregation.js';
|
||||
import type { Env } from './types.js';
|
||||
|
||||
type MockStmt = {
|
||||
bind: ReturnType<typeof vi.fn>;
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
all: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MockDb = D1Database & {
|
||||
_mockStmt: MockStmt;
|
||||
_mockBind: ReturnType<typeof vi.fn>;
|
||||
_mockAll: ReturnType<typeof vi.fn>;
|
||||
_mockRun: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createMockDatabase(): MockDb {
|
||||
const mockRun = vi.fn().mockResolvedValue({});
|
||||
const mockAll = vi.fn().mockResolvedValue({ results: [] });
|
||||
const mockBind = vi.fn().mockReturnThis();
|
||||
|
||||
const mockStmt = {
|
||||
bind: mockBind,
|
||||
run: mockRun,
|
||||
all: mockAll,
|
||||
};
|
||||
|
||||
const mockPrepare = vi.fn().mockReturnValue(mockStmt);
|
||||
const mockBatch = vi.fn().mockResolvedValue([]);
|
||||
|
||||
return {
|
||||
prepare: mockPrepare,
|
||||
batch: mockBatch,
|
||||
_mockStmt: mockStmt,
|
||||
_mockBind: mockBind,
|
||||
_mockAll: mockAll,
|
||||
_mockRun: mockRun,
|
||||
} as unknown as MockDb;
|
||||
}
|
||||
|
||||
describe('handleAggregation', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-15T12:30:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('aggregates data and deletes old records', async () => {
|
||||
const db = createMockDatabase();
|
||||
const env: Env = { DB: db, MONITORS_CONFIG: '' };
|
||||
|
||||
await handleAggregation(env);
|
||||
|
||||
expect(db.prepare).toHaveBeenCalledWith(expect.stringContaining('SELECT'));
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining('DELETE FROM check_results WHERE')
|
||||
);
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining('DELETE FROM check_results_hourly WHERE')
|
||||
);
|
||||
});
|
||||
|
||||
it('inserts aggregated data when results exist', async () => {
|
||||
const db = createMockDatabase();
|
||||
const env: Env = { DB: db, MONITORS_CONFIG: '' };
|
||||
|
||||
db._mockAll.mockResolvedValueOnce({
|
||||
results: [
|
||||
{
|
||||
monitor_name: 'test-monitor',
|
||||
total_checks: 60,
|
||||
successful_checks: 58,
|
||||
failed_checks: 2,
|
||||
avg_response_time_ms: 150.5,
|
||||
min_response_time_ms: 100,
|
||||
max_response_time_ms: 300,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await handleAggregation(env);
|
||||
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO check_results_hourly')
|
||||
);
|
||||
expect(db.batch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips insert when no results to aggregate', async () => {
|
||||
const db = createMockDatabase();
|
||||
const env: Env = { DB: db, MONITORS_CONFIG: '' };
|
||||
|
||||
db._mockAll.mockResolvedValueOnce({ results: [] });
|
||||
|
||||
await handleAggregation(env);
|
||||
|
||||
expect(db.batch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rounds average response time', async () => {
|
||||
const db = createMockDatabase();
|
||||
const env: Env = { DB: db, MONITORS_CONFIG: '' };
|
||||
|
||||
db._mockAll.mockResolvedValueOnce({
|
||||
results: [
|
||||
{
|
||||
monitor_name: 'test',
|
||||
total_checks: 10,
|
||||
successful_checks: 10,
|
||||
failed_checks: 0,
|
||||
avg_response_time_ms: 123.456,
|
||||
min_response_time_ms: 100,
|
||||
max_response_time_ms: 150,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await handleAggregation(env);
|
||||
|
||||
expect(db._mockBind).toHaveBeenCalledWith('test', expect.any(Number), 10, 10, 0, 123, 100, 150);
|
||||
});
|
||||
|
||||
it('handles null avg_response_time_ms', async () => {
|
||||
const db = createMockDatabase();
|
||||
const env: Env = { DB: db, MONITORS_CONFIG: '' };
|
||||
|
||||
db._mockAll.mockResolvedValueOnce({
|
||||
results: [
|
||||
{
|
||||
monitor_name: 'test',
|
||||
total_checks: 10,
|
||||
successful_checks: 0,
|
||||
failed_checks: 10,
|
||||
avg_response_time_ms: undefined,
|
||||
min_response_time_ms: undefined,
|
||||
max_response_time_ms: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await handleAggregation(env);
|
||||
|
||||
expect(db._mockBind).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.any(Number),
|
||||
10,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
112
src/aggregation.ts
Normal file
112
src/aggregation.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Env } from './types.js';
|
||||
|
||||
const rawRetentionDays = 7;
|
||||
const hourlyRetentionDays = 90;
|
||||
const batchLimit = 100;
|
||||
|
||||
function chunkArray<T>(array: T[], chunkSize: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += chunkSize) {
|
||||
chunks.push(array.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
type AggregationRow = {
|
||||
monitor_name: string;
|
||||
total_checks: number;
|
||||
successful_checks: number;
|
||||
failed_checks: number;
|
||||
avg_response_time_ms: number | undefined;
|
||||
min_response_time_ms: number | undefined;
|
||||
max_response_time_ms: number | undefined;
|
||||
};
|
||||
|
||||
export async function handleAggregation(env: Env): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneHourAgo = now - 3600;
|
||||
const hourStart = Math.floor(oneHourAgo / 3600) * 3600;
|
||||
|
||||
await aggregateHour(env.DB, hourStart);
|
||||
await deleteOldRawData(env.DB, now);
|
||||
await deleteOldHourlyData(env.DB, now);
|
||||
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: 'aggregation_complete',
|
||||
hour: new Date(hourStart * 1000).toISOString(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function aggregateHour(database: D1Database, hourStart: number): Promise<void> {
|
||||
const hourEnd = hourStart + 3600;
|
||||
|
||||
const result = await database
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
monitor_name,
|
||||
COUNT(*) as total_checks,
|
||||
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as successful_checks,
|
||||
SUM(CASE WHEN status = 'down' THEN 1 ELSE 0 END) as failed_checks,
|
||||
AVG(response_time_ms) as avg_response_time_ms,
|
||||
MIN(response_time_ms) as min_response_time_ms,
|
||||
MAX(response_time_ms) as max_response_time_ms
|
||||
FROM check_results
|
||||
WHERE checked_at >= ? AND checked_at < ?
|
||||
GROUP BY monitor_name
|
||||
`
|
||||
)
|
||||
.bind(hourStart, hourEnd)
|
||||
.all<AggregationRow>();
|
||||
|
||||
if (!result.results || result.results.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stmt = database.prepare(`
|
||||
INSERT INTO check_results_hourly
|
||||
(monitor_name, hour_timestamp, total_checks, successful_checks, failed_checks, avg_response_time_ms, min_response_time_ms, max_response_time_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(monitor_name, hour_timestamp) DO UPDATE SET
|
||||
total_checks = excluded.total_checks,
|
||||
successful_checks = excluded.successful_checks,
|
||||
failed_checks = excluded.failed_checks,
|
||||
avg_response_time_ms = excluded.avg_response_time_ms,
|
||||
min_response_time_ms = excluded.min_response_time_ms,
|
||||
max_response_time_ms = excluded.max_response_time_ms
|
||||
`);
|
||||
|
||||
const batch = result.results.map((row: AggregationRow) =>
|
||||
stmt.bind(
|
||||
row.monitor_name,
|
||||
hourStart,
|
||||
row.total_checks,
|
||||
row.successful_checks,
|
||||
row.failed_checks,
|
||||
Math.round(row.avg_response_time_ms ?? 0),
|
||||
row.min_response_time_ms,
|
||||
row.max_response_time_ms
|
||||
)
|
||||
);
|
||||
|
||||
const chunks = chunkArray(batch, batchLimit);
|
||||
for (const chunk of chunks) {
|
||||
await database.batch(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOldRawData(database: D1Database, now: number): Promise<void> {
|
||||
const cutoff = now - rawRetentionDays * 24 * 3600;
|
||||
await database.prepare('DELETE FROM check_results WHERE checked_at < ?').bind(cutoff).run();
|
||||
}
|
||||
|
||||
async function deleteOldHourlyData(database: D1Database, now: number): Promise<void> {
|
||||
const cutoff = now - hourlyRetentionDays * 24 * 3600;
|
||||
await database
|
||||
.prepare('DELETE FROM check_results_hourly WHERE hour_timestamp < ?')
|
||||
.bind(cutoff)
|
||||
.run();
|
||||
}
|
||||
3
src/alert/index.ts
Normal file
3
src/alert/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { formatWebhookPayload } from './webhook.js';
|
||||
export type { TemplateData, WebhookPayload } from './types.js';
|
||||
export type { FormatWebhookPayloadOptions } from './webhook.js';
|
||||
28
src/alert/types.ts
Normal file
28
src/alert/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type TemplateData = {
|
||||
event: string;
|
||||
monitor: {
|
||||
name: string;
|
||||
type: string;
|
||||
target: string;
|
||||
};
|
||||
status: {
|
||||
current: string;
|
||||
previous: string;
|
||||
consecutiveFailures: number;
|
||||
lastStatusChange: string;
|
||||
downtimeDurationSeconds: number;
|
||||
};
|
||||
check: {
|
||||
timestamp: string;
|
||||
responseTimeMs: number;
|
||||
attempts: number;
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebhookPayload = {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
};
|
||||
191
src/alert/webhook.test.ts
Normal file
191
src/alert/webhook.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Config } from '../config/types.js';
|
||||
import { formatWebhookPayload } from './webhook.js';
|
||||
|
||||
describe('formatWebhookPayload', () => {
|
||||
const baseConfig: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'webhook' as const,
|
||||
url: 'https://example.com/hook',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
bodyTemplate:
|
||||
'{"monitor":"{{monitor.name}}","status":"{{status.current}}","error":"{{check.error}}"}',
|
||||
},
|
||||
],
|
||||
monitors: [
|
||||
{
|
||||
name: 'api-health',
|
||||
type: 'http',
|
||||
target: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: ['test'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('renders template with correct values', () => {
|
||||
const payload = formatWebhookPayload({
|
||||
alertName: 'test',
|
||||
monitorName: 'api-health',
|
||||
alertType: 'down',
|
||||
error: 'Connection timeout',
|
||||
timestamp: 1_711_882_800,
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(payload.url).toBe('https://example.com/hook');
|
||||
expect(payload.method).toBe('POST');
|
||||
expect(payload.headers['Content-Type']).toBe('application/json');
|
||||
expect(payload.body).toBe(
|
||||
'{"monitor":"api-health","status":"down","error":"Connection timeout"}'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty payload for missing webhook', () => {
|
||||
const payload = formatWebhookPayload({
|
||||
alertName: 'nonexistent',
|
||||
monitorName: 'api-health',
|
||||
alertType: 'down',
|
||||
error: '',
|
||||
timestamp: 0,
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(payload.url).toBe('');
|
||||
expect(payload.body).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty payload for missing monitor', () => {
|
||||
const payload = formatWebhookPayload({
|
||||
alertName: 'test',
|
||||
monitorName: 'nonexistent',
|
||||
alertType: 'down',
|
||||
error: '',
|
||||
timestamp: 0,
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(payload.url).toBe('');
|
||||
expect(payload.body).toBe('');
|
||||
});
|
||||
|
||||
it('escapes special characters in JSON', () => {
|
||||
const config: Config = {
|
||||
...baseConfig,
|
||||
alerts: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'webhook' as const,
|
||||
url: 'https://example.com',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
bodyTemplate: '{"name":"{{monitor.name}}"}',
|
||||
},
|
||||
],
|
||||
monitors: [
|
||||
{
|
||||
name: 'test"with"quotes',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const payload = formatWebhookPayload({
|
||||
alertName: 'test',
|
||||
monitorName: 'test"with"quotes',
|
||||
alertType: 'down',
|
||||
error: '',
|
||||
timestamp: 0,
|
||||
config,
|
||||
});
|
||||
|
||||
expect(payload.body).toBe(String.raw`{"name":"test\"with\"quotes"}`);
|
||||
});
|
||||
|
||||
it('renders status.emoji template variable', () => {
|
||||
const config: Config = {
|
||||
...baseConfig,
|
||||
alerts: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'webhook' as const,
|
||||
url: 'https://example.com',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
bodyTemplate: '{"text":"{{status.emoji}} {{monitor.name}} is {{status.current}}"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const downPayload = formatWebhookPayload({
|
||||
alertName: 'test',
|
||||
monitorName: 'api-health',
|
||||
alertType: 'down',
|
||||
error: 'timeout',
|
||||
timestamp: 0,
|
||||
config,
|
||||
});
|
||||
expect(downPayload.body).toBe('{"text":"🔴 api-health is down"}');
|
||||
|
||||
const recoveryPayload = formatWebhookPayload({
|
||||
alertName: 'test',
|
||||
monitorName: 'api-health',
|
||||
alertType: 'recovery',
|
||||
error: '',
|
||||
timestamp: 0,
|
||||
config,
|
||||
});
|
||||
expect(recoveryPayload.body).toBe('{"text":"🟢 api-health is recovery"}');
|
||||
});
|
||||
|
||||
it('handles unknown template keys gracefully', () => {
|
||||
const config: Config = {
|
||||
...baseConfig,
|
||||
alerts: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'webhook' as const,
|
||||
url: 'https://example.com',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
bodyTemplate: '{"unknown":"{{unknown.key}}"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const payload = formatWebhookPayload({
|
||||
alertName: 'test',
|
||||
monitorName: 'api-health',
|
||||
alertType: 'down',
|
||||
error: '',
|
||||
timestamp: 0,
|
||||
config,
|
||||
});
|
||||
|
||||
expect(payload.body).toBe('{"unknown":""}');
|
||||
});
|
||||
});
|
||||
107
src/alert/webhook.ts
Normal file
107
src/alert/webhook.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Config, WebhookAlert } from '../config/types.js';
|
||||
import { statusEmoji } from '../utils/status-emoji.js';
|
||||
import type { TemplateData, WebhookPayload } from './types.js';
|
||||
|
||||
const templateRegex = /\{\{([^\}]+)\}\}/gv;
|
||||
|
||||
function jsonEscape(s: string): string {
|
||||
const escaped = JSON.stringify(s);
|
||||
return escaped.slice(1, -1);
|
||||
}
|
||||
|
||||
function invertStatus(status: string): string {
|
||||
return status === 'down' ? 'up' : 'down';
|
||||
}
|
||||
|
||||
function resolveKey(key: string, data: TemplateData): string {
|
||||
const trimmedKey = key.trim();
|
||||
|
||||
const resolvers = new Map<string, () => string>([
|
||||
['event', () => jsonEscape(data.event)],
|
||||
['monitor.name', () => jsonEscape(data.monitor.name)],
|
||||
['monitor.type', () => jsonEscape(data.monitor.type)],
|
||||
['monitor.target', () => jsonEscape(data.monitor.target)],
|
||||
['status.current', () => jsonEscape(data.status.current)],
|
||||
['status.previous', () => jsonEscape(data.status.previous)],
|
||||
['status.emoji', () => jsonEscape(statusEmoji(data.status.current))],
|
||||
['status.consecutive_failures', () => String(data.status.consecutiveFailures)],
|
||||
['status.last_status_change', () => jsonEscape(data.status.lastStatusChange)],
|
||||
['status.downtime_duration_seconds', () => String(data.status.downtimeDurationSeconds)],
|
||||
['check.timestamp', () => jsonEscape(data.check.timestamp)],
|
||||
['check.response_time_ms', () => String(data.check.responseTimeMs)],
|
||||
['check.attempts', () => String(data.check.attempts)],
|
||||
['check.error', () => jsonEscape(data.check.error)],
|
||||
]);
|
||||
|
||||
const resolver = resolvers.get(trimmedKey);
|
||||
return resolver ? resolver() : '';
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, data: TemplateData): string {
|
||||
return template.replaceAll(templateRegex, (_match, key: string) => resolveKey(key, data));
|
||||
}
|
||||
|
||||
export type FormatWebhookPayloadOptions = {
|
||||
alertName: string;
|
||||
monitorName: string;
|
||||
alertType: string;
|
||||
error: string;
|
||||
timestamp: number;
|
||||
config: Config;
|
||||
};
|
||||
|
||||
export function formatWebhookPayload(options: FormatWebhookPayloadOptions): WebhookPayload {
|
||||
const { alertName, monitorName, alertType, error, timestamp, config } = options;
|
||||
|
||||
const alert = config.alerts.find(a => a.name === alertName && a.type === 'webhook');
|
||||
if (!alert || alert.type !== 'webhook') {
|
||||
return {
|
||||
url: '',
|
||||
method: '',
|
||||
headers: {},
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
const webhookAlert = alert as WebhookAlert;
|
||||
|
||||
const monitor = config.monitors.find(m => m.name === monitorName);
|
||||
if (!monitor) {
|
||||
return {
|
||||
url: '',
|
||||
method: '',
|
||||
headers: {},
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
const data: TemplateData = {
|
||||
event: `monitor.${alertType}`,
|
||||
monitor: {
|
||||
name: monitor.name,
|
||||
type: monitor.type,
|
||||
target: monitor.target,
|
||||
},
|
||||
status: {
|
||||
current: alertType,
|
||||
previous: invertStatus(alertType),
|
||||
consecutiveFailures: 0,
|
||||
lastStatusChange: '',
|
||||
downtimeDurationSeconds: 0,
|
||||
},
|
||||
check: {
|
||||
timestamp: new Date(timestamp * 1000).toISOString(),
|
||||
responseTimeMs: 0,
|
||||
attempts: 0,
|
||||
error,
|
||||
},
|
||||
};
|
||||
|
||||
const body = renderTemplate(webhookAlert.bodyTemplate, data);
|
||||
|
||||
return {
|
||||
url: webhookAlert.url,
|
||||
method: webhookAlert.method,
|
||||
headers: webhookAlert.headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
137
src/check-execution.test.ts
Normal file
137
src/check-execution.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { HttpCheckRequest, CheckResult, Env } from './types.js';
|
||||
|
||||
// Mock the executeLocalCheck function and other dependencies
|
||||
vi.mock('./checks/http.js', () => ({
|
||||
executeHttpCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./checks/tcp.js', () => ({
|
||||
executeTcpCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./checks/dns.js', () => ({
|
||||
executeDnsCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the functions from index.ts
|
||||
// We need to import the module and extract the functions
|
||||
const createMockEnv = (overrides: Partial<Env> = {}): Env => ({
|
||||
DB: {} as any,
|
||||
MONITORS_CONFIG: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createCheckRequest = (overrides: Partial<HttpCheckRequest> = {}): HttpCheckRequest => ({
|
||||
name: 'test-check',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const _createMockCheckResult = (overrides: Partial<CheckResult> = {}): CheckResult => ({
|
||||
name: 'test-check',
|
||||
status: 'up',
|
||||
responseTimeMs: 100,
|
||||
error: '',
|
||||
attempts: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// We'll test the logic by recreating the executeCheck function based on the implementation
|
||||
describe('check execution with regional support', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('executeCheck logic', () => {
|
||||
it('should execute check locally when no region is specified', async () => {
|
||||
// This test verifies the logic from index.ts:82-129
|
||||
// When check.region is not specified, it should fall through to executeLocalCheck
|
||||
const check = createCheckRequest({ region: undefined });
|
||||
const env = createMockEnv({ REGIONAL_CHECKER_DO: undefined });
|
||||
|
||||
// The logic would be: if (!check.region || !env.REGIONAL_CHECKER_DO) -> executeLocalCheck
|
||||
expect(check.region).toBeUndefined();
|
||||
expect(env.REGIONAL_CHECKER_DO).toBeUndefined();
|
||||
// Therefore, executeLocalCheck should be called
|
||||
});
|
||||
|
||||
it('should execute check locally when REGIONAL_CHECKER_DO is not available', async () => {
|
||||
const check = createCheckRequest({ region: 'weur' });
|
||||
const env = createMockEnv({ REGIONAL_CHECKER_DO: undefined });
|
||||
|
||||
// The logic would be: if (check.region && env.REGIONAL_CHECKER_DO) -> false
|
||||
expect(check.region).toBe('weur');
|
||||
expect(env.REGIONAL_CHECKER_DO).toBeUndefined();
|
||||
// Therefore, executeLocalCheck should be called
|
||||
});
|
||||
|
||||
it('should attempt regional check when region and REGIONAL_CHECKER_DO are available', async () => {
|
||||
const check = createCheckRequest({ region: 'weur' });
|
||||
const mockDo = {
|
||||
idFromName: vi.fn(),
|
||||
get: vi.fn(),
|
||||
};
|
||||
const env = createMockEnv({ REGIONAL_CHECKER_DO: mockDo as any });
|
||||
|
||||
// The logic would be: if (check.region && env.REGIONAL_CHECKER_DO) -> true
|
||||
expect(check.region).toBe('weur');
|
||||
expect(env.REGIONAL_CHECKER_DO).toBe(mockDo);
|
||||
// Therefore, it should attempt regional check
|
||||
});
|
||||
});
|
||||
|
||||
describe('regional check fallback behavior', () => {
|
||||
it('should fall back to local check when regional check fails', async () => {
|
||||
// This tests the catch block in index.ts:118-124
|
||||
// When regional check throws an error, it should fall back to executeLocalCheck
|
||||
const check = createCheckRequest({ region: 'weur' });
|
||||
const mockDo = {
|
||||
idFromName: vi.fn(),
|
||||
get: vi.fn(),
|
||||
};
|
||||
const env = createMockEnv({ REGIONAL_CHECKER_DO: mockDo as any });
|
||||
|
||||
// Simulating regional check failure
|
||||
// The code would: try { regional check } catch { executeLocalCheck() }
|
||||
expect(check.region).toBe('weur');
|
||||
expect(env.REGIONAL_CHECKER_DO).toBe(mockDo);
|
||||
// On error in regional check, it should fall back to local
|
||||
});
|
||||
});
|
||||
|
||||
describe('Durable Object interaction pattern', () => {
|
||||
it('should create DO ID from monitor name', () => {
|
||||
const check = createCheckRequest({ name: 'my-monitor', region: 'weur' });
|
||||
const mockDo = {
|
||||
idFromName: vi.fn().mockReturnValue('mock-id'),
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
// The pattern is: env.REGIONAL_CHECKER_DO.idFromName(check.name)
|
||||
const doId = mockDo.idFromName(check.name);
|
||||
expect(mockDo.idFromName).toHaveBeenCalledWith('my-monitor');
|
||||
expect(doId).toBe('mock-id');
|
||||
});
|
||||
|
||||
it('should get DO stub with location hint', () => {
|
||||
createCheckRequest({ region: 'weur' });
|
||||
const mockDo = {
|
||||
idFromName: vi.fn().mockReturnValue('mock-id'),
|
||||
get: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
// The pattern is: env.REGIONAL_CHECKER_DO.get(doId, { locationHint: check.region })
|
||||
mockDo.get('mock-id', { locationHint: 'weur' });
|
||||
expect(mockDo.get).toHaveBeenCalledWith('mock-id', { locationHint: 'weur' });
|
||||
});
|
||||
});
|
||||
});
|
||||
153
src/checker/checker.test.ts
Normal file
153
src/checker/checker.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Config } from '../config/types.js';
|
||||
import type { HttpCheckRequest } from '../types.js';
|
||||
import { prepareChecks } from './checker.js';
|
||||
|
||||
describe('prepareChecks', () => {
|
||||
it('converts monitors to check requests', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [
|
||||
{
|
||||
name: 'test-http',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const checks = prepareChecks(config);
|
||||
|
||||
expect(checks).toHaveLength(1);
|
||||
expect(checks[0].name).toBe('test-http');
|
||||
expect(checks[0].type).toBe('http');
|
||||
expect(checks[0].target).toBe('https://example.com');
|
||||
const httpCheck = checks[0] as HttpCheckRequest;
|
||||
expect(httpCheck.method).toBe('GET');
|
||||
expect(httpCheck.expectedStatus).toBe(200);
|
||||
expect(checks[0].timeoutMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('returns empty array for empty monitors', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 0,
|
||||
defaultRetryDelayMs: 0,
|
||||
defaultTimeoutMs: 0,
|
||||
defaultFailureThreshold: 0,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [],
|
||||
};
|
||||
|
||||
const checks = prepareChecks(config);
|
||||
expect(checks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('omits empty optional fields', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [
|
||||
{
|
||||
name: 'test-tcp',
|
||||
type: 'tcp',
|
||||
target: 'example.com:443',
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const checks = prepareChecks(config);
|
||||
|
||||
expect('method' in checks[0]).toBe(false);
|
||||
expect('expectedStatus' in checks[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('passes headers through for HTTP monitors', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [
|
||||
{
|
||||
name: 'test-http-headers',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const checks = prepareChecks(config);
|
||||
const httpCheck = checks[0] as HttpCheckRequest;
|
||||
|
||||
expect(httpCheck.headers).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
|
||||
it('omits headers when empty', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [
|
||||
{
|
||||
name: 'test-http-no-headers',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const checks = prepareChecks(config);
|
||||
const httpCheck = checks[0] as HttpCheckRequest;
|
||||
|
||||
expect(httpCheck.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
45
src/checker/checker.ts
Normal file
45
src/checker/checker.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Config } from '../config/types.js';
|
||||
import type { CheckRequest } from './types.js';
|
||||
|
||||
export function prepareChecks(config: Config): CheckRequest[] {
|
||||
return config.monitors.map(m => {
|
||||
const base = {
|
||||
name: m.name,
|
||||
target: m.target,
|
||||
timeoutMs: m.timeoutMs,
|
||||
retries: m.retries,
|
||||
retryDelayMs: m.retryDelayMs,
|
||||
region: m.region,
|
||||
};
|
||||
|
||||
switch (m.type) {
|
||||
case 'http': {
|
||||
return {
|
||||
...base,
|
||||
type: m.type,
|
||||
method: m.method || undefined,
|
||||
expectedStatus: m.expectedStatus || undefined,
|
||||
headers: Object.keys(m.headers).length > 0 ? m.headers : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case 'tcp': {
|
||||
return { ...base, type: m.type };
|
||||
}
|
||||
|
||||
case 'dns': {
|
||||
return {
|
||||
...base,
|
||||
type: m.type,
|
||||
recordType: m.recordType || undefined,
|
||||
expectedValues: m.expectedValues.length > 0 ? m.expectedValues : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = m;
|
||||
throw new Error(`Unknown monitor type: ${String(_exhaustive)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
2
src/checker/index.ts
Normal file
2
src/checker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { prepareChecks } from './checker.js';
|
||||
export type { CheckRequest, HttpCheckRequest, TcpCheckRequest, DnsCheckRequest } from './types.js';
|
||||
1
src/checker/types.ts
Normal file
1
src/checker/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { CheckRequest, HttpCheckRequest, TcpCheckRequest, DnsCheckRequest } from '../types.js';
|
||||
251
src/checks/dns.test.ts
Normal file
251
src/checks/dns.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { DnsCheckRequest } from '../types.js';
|
||||
import { executeDnsCheck } from './dns.js';
|
||||
|
||||
const createCheckRequest = (overrides: Partial<DnsCheckRequest> = {}): DnsCheckRequest => ({
|
||||
name: 'test-dns',
|
||||
type: 'dns',
|
||||
target: 'example.com',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('executeDnsCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns up status on successful DNS resolution', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.name).toBe('test-dns');
|
||||
expect(result.error).toBe('');
|
||||
expect(result.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('uses correct DoH URL with record type', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: 'mx.example.com' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ recordType: 'MX' });
|
||||
await executeDnsCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('type=MX'), expect.any(Object));
|
||||
});
|
||||
|
||||
it('defaults to A record type', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
await executeDnsCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('type=A'), expect.any(Object));
|
||||
});
|
||||
|
||||
it('returns down status when DNS query fails', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('DNS query failed');
|
||||
});
|
||||
|
||||
it('validates expected values', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
});
|
||||
|
||||
it('returns down status when expected values do not match', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '1.2.3.4' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
retries: 0,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Expected 93.184.216.34');
|
||||
});
|
||||
|
||||
it('validates multiple expected values', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Answer: [{ data: '93.184.216.34' }, { data: '93.184.216.35' }],
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34', '93.184.216.35'],
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
});
|
||||
|
||||
it('fails when not all expected values are found', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34', '93.184.216.35'],
|
||||
retries: 0,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('retries on failure and eventually succeeds', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('retries on failure and eventually fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('Network error');
|
||||
expect(result.attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('handles empty Answer array', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
retries: 0,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('handles missing Answer field', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
retries: 0,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('passes when no expected values specified', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ expectedValues: undefined });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
});
|
||||
|
||||
it('handles unknown error types', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue('string error');
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('encodes target in URL', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ target: 'sub.example.com' });
|
||||
await executeDnsCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('name=sub.example.com'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('retries on wrong expected values then succeeds', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: 'wrong' }] }),
|
||||
} as unknown as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
retries: 2,
|
||||
retryDelayMs: 10,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
114
src/checks/dns.ts
Normal file
114
src/checks/dns.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { DnsCheckRequest, CheckResult } from '../types.js';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
type DnsResponse = {
|
||||
Answer?: Array<{ data: string }>;
|
||||
};
|
||||
|
||||
export async function executeDnsCheck(check: DnsCheckRequest): Promise<CheckResult> {
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
let lastError = '';
|
||||
|
||||
const recordType = check.recordType ?? 'A';
|
||||
const dohUrl = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(check.target)}&type=${recordType}`;
|
||||
|
||||
for (let i = 0; i <= check.retries; i++) {
|
||||
attempts++;
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, check.timeoutMs);
|
||||
|
||||
const response = await fetch(dohUrl, {
|
||||
headers: { Accept: 'application/dns-json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = `DNS query failed: ${response.status}`;
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
const data: DnsResponse = await response.json();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
const { expectedValues } = check;
|
||||
if (!expectedValues || expectedValues.length === 0) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'up',
|
||||
responseTimeMs: responseTime,
|
||||
error: '',
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedValues = data.Answer?.map(a => a.data) ?? [];
|
||||
const allExpectedFound = expectedValues.every(expected => resolvedValues.includes(expected));
|
||||
|
||||
if (allExpectedFound) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'up',
|
||||
responseTimeMs: responseTime,
|
||||
error: '',
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
lastError = `Expected ${expectedValues.join(', ')}, got ${resolvedValues.join(', ')}`;
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: responseTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
} catch (error) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
lastError = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
}
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
188
src/checks/http.test.ts
Normal file
188
src/checks/http.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { HttpCheckRequest } from '../types.js';
|
||||
import { executeHttpCheck } from './http.js';
|
||||
|
||||
const createCheckRequest = (overrides: Partial<HttpCheckRequest> = {}): HttpCheckRequest => ({
|
||||
name: 'test-http',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('executeHttpCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns up status on successful response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.name).toBe('test-http');
|
||||
expect(result.error).toBe('');
|
||||
expect(result.attempts).toBe(1);
|
||||
expect(result.responseTimeMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('returns down status on wrong expected status code', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ expectedStatus: 201 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Expected status 201, got 200');
|
||||
});
|
||||
|
||||
it('matches expected status code when correct', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ expectedStatus: 201 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
});
|
||||
|
||||
it('retries on failure and eventually fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('Network error');
|
||||
expect(result.attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('retries on failure and eventually succeeds', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('uses correct HTTP method', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ method: 'POST' });
|
||||
await executeHttpCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to GET method', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
await executeHttpCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('sends default User-Agent header', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
await executeHttpCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'User-Agent': 'atalaya-uptime' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('merges custom headers with defaults', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
headers: { Authorization: 'Bearer token123' },
|
||||
});
|
||||
await executeHttpCheck(check);
|
||||
|
||||
const callHeaders = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(callHeaders['User-Agent']).toBe('atalaya-uptime');
|
||||
expect(callHeaders['Authorization']).toBe('Bearer token123');
|
||||
});
|
||||
|
||||
it('allows monitor headers to override defaults', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
headers: { 'User-Agent': 'custom-agent' },
|
||||
});
|
||||
await executeHttpCheck(check);
|
||||
|
||||
const callHeaders = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(callHeaders['User-Agent']).toBe('custom-agent');
|
||||
});
|
||||
|
||||
it('handles abort signal timeout', async () => {
|
||||
vi.mocked(fetch).mockImplementation(
|
||||
async () =>
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('The operation was aborted'));
|
||||
}, 100);
|
||||
})
|
||||
);
|
||||
|
||||
const check = createCheckRequest({ timeoutMs: 50, retries: 0 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('retries on wrong status code', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce({ ok: false, status: 500 } as unknown as Response)
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ expectedStatus: 200, retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('handles unknown error types', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue('string error');
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
82
src/checks/http.ts
Normal file
82
src/checks/http.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { HttpCheckRequest, CheckResult } from '../types.js';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
const DEFAULT_HEADERS: Record<string, string> = {
|
||||
'User-Agent': 'atalaya-uptime',
|
||||
};
|
||||
|
||||
export async function executeHttpCheck(check: HttpCheckRequest): Promise<CheckResult> {
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
let lastError = '';
|
||||
const headers = { ...DEFAULT_HEADERS, ...check.headers };
|
||||
|
||||
for (let i = 0; i <= check.retries; i++) {
|
||||
attempts++;
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, check.timeoutMs);
|
||||
|
||||
const response = await fetch(check.target, {
|
||||
method: check.method ?? 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (check.expectedStatus && response.status !== check.expectedStatus) {
|
||||
lastError = `Expected status ${check.expectedStatus}, got ${response.status}`;
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: responseTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'up',
|
||||
responseTimeMs: responseTime,
|
||||
error: '',
|
||||
attempts,
|
||||
};
|
||||
} catch (error) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
lastError = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
}
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
197
src/checks/tcp.test.ts
Normal file
197
src/checks/tcp.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { connect } from 'cloudflare:sockets';
|
||||
import type { TcpCheckRequest } from '../types.js';
|
||||
import { executeTcpCheck } from './tcp.js';
|
||||
|
||||
type Socket = ReturnType<typeof connect>;
|
||||
|
||||
vi.mock('cloudflare:sockets', () => ({
|
||||
connect: vi.fn(),
|
||||
}));
|
||||
|
||||
const createCheckRequest = (overrides: Partial<TcpCheckRequest> = {}): TcpCheckRequest => ({
|
||||
name: 'test-tcp',
|
||||
type: 'tcp',
|
||||
target: 'example.com:443',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
type MockSocket = {
|
||||
opened: Promise<unknown>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createMockSocket(options: { shouldOpen?: boolean; openDelay?: number } = {}): MockSocket {
|
||||
const { shouldOpen = true, openDelay = 0 } = options;
|
||||
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
let mockOpened: Promise<unknown>;
|
||||
if (shouldOpen) {
|
||||
mockOpened = new Promise(resolve => {
|
||||
setTimeout(resolve, openDelay);
|
||||
});
|
||||
} else {
|
||||
mockOpened = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Connection refused'));
|
||||
}, openDelay);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
opened: mockOpened,
|
||||
close: mockClose,
|
||||
};
|
||||
}
|
||||
|
||||
describe('executeTcpCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns down status for invalid target format', async () => {
|
||||
const check = createCheckRequest({ target: 'invalid-target' });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Invalid target format');
|
||||
expect(result.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('returns down status for invalid port number (NaN)', async () => {
|
||||
const check = createCheckRequest({ target: 'example.com:abc' });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Invalid port number');
|
||||
});
|
||||
|
||||
it('returns down status for port out of range (0)', async () => {
|
||||
const check = createCheckRequest({ target: 'example.com:0' });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Invalid port number');
|
||||
});
|
||||
|
||||
it('returns down status for port out of range (65536)', async () => {
|
||||
const check = createCheckRequest({ target: 'example.com:65536' });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Invalid port number');
|
||||
});
|
||||
|
||||
it('returns up status on successful connection', async () => {
|
||||
vi.mocked(connect).mockReturnValue(createMockSocket() as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest();
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.name).toBe('test-tcp');
|
||||
expect(result.error).toBe('');
|
||||
expect(result.attempts).toBe(1);
|
||||
expect(connect).toHaveBeenCalledWith({ hostname: 'example.com', port: 443 });
|
||||
});
|
||||
|
||||
it('returns down status on connection failure', async () => {
|
||||
vi.mocked(connect).mockReturnValue(
|
||||
createMockSocket({ shouldOpen: false }) as unknown as Socket
|
||||
);
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('retries on failure and eventually succeeds', async () => {
|
||||
vi.mocked(connect)
|
||||
.mockReturnValueOnce(createMockSocket({ shouldOpen: false }) as unknown as Socket)
|
||||
.mockReturnValueOnce(createMockSocket({ shouldOpen: true }) as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('retries on failure and eventually fails', async () => {
|
||||
vi.mocked(connect).mockReturnValue(
|
||||
createMockSocket({ shouldOpen: false }) as unknown as Socket
|
||||
);
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('handles connection timeout', async () => {
|
||||
vi.mocked(connect).mockReturnValue({
|
||||
opened: new Promise(() => {
|
||||
// Never resolves
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ timeoutMs: 50, retries: 0 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('timeout');
|
||||
});
|
||||
|
||||
it('closes socket after successful connection', async () => {
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(connect).mockReturnValue(mockSocket as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest();
|
||||
await executeTcpCheck(check);
|
||||
|
||||
expect(mockSocket.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles socket close error gracefully in finally block', async () => {
|
||||
const mockSocket = {
|
||||
opened: Promise.reject(new Error('Connection failed')),
|
||||
close: vi.fn().mockRejectedValue(new Error('Close error')),
|
||||
};
|
||||
vi.mocked(connect).mockReturnValue(mockSocket as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(mockSocket.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles unknown error types', async () => {
|
||||
vi.mocked(connect).mockReturnValue({
|
||||
opened: Promise.reject(new Error('string error')),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('string error');
|
||||
});
|
||||
|
||||
it('parses port correctly', async () => {
|
||||
vi.mocked(connect).mockReturnValue(createMockSocket() as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ target: 'db.example.com:5432' });
|
||||
await executeTcpCheck(check);
|
||||
|
||||
expect(connect).toHaveBeenCalledWith({ hostname: 'db.example.com', port: 5432 });
|
||||
});
|
||||
});
|
||||
94
src/checks/tcp.ts
Normal file
94
src/checks/tcp.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { connect } from 'cloudflare:sockets';
|
||||
import type { TcpCheckRequest, CheckResult } from '../types.js';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
export async function executeTcpCheck(check: TcpCheckRequest): Promise<CheckResult> {
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
let lastError = '';
|
||||
|
||||
const parts = check.target.split(':');
|
||||
if (parts.length !== 2) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'Invalid target format (expected host:port)',
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const [hostname, portString] = parts;
|
||||
const port = Number.parseInt(portString, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port > 65_535) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'Invalid port number',
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i <= check.retries; i++) {
|
||||
attempts++;
|
||||
let socket: ReturnType<typeof connect> | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
socket = connect({ hostname, port });
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error('Connection timeout'));
|
||||
}, check.timeoutMs);
|
||||
});
|
||||
|
||||
await Promise.race([socket.opened, timeoutPromise]);
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
await socket.close();
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'up',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: '',
|
||||
attempts,
|
||||
};
|
||||
} catch (error) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
lastError = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
}
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
try {
|
||||
await socket.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
27
src/checks/utils.test.ts
Normal file
27
src/checks/utils.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
describe('sleep', () => {
|
||||
it('resolves after specified time', async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = sleep(1000);
|
||||
vi.advanceTimersByTime(1000);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits approximately the specified time', async () => {
|
||||
const start = Date.now();
|
||||
await sleep(50);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(40);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('handles zero delay', async () => {
|
||||
const start = Date.now();
|
||||
await sleep(0);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
5
src/checks/utils.ts
Normal file
5
src/checks/utils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
229
src/config/config.test.ts
Normal file
229
src/config/config.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseConfig } from './config.js';
|
||||
|
||||
describe('parseConfig', () => {
|
||||
it('parses basic YAML config', () => {
|
||||
const yaml = `
|
||||
settings:
|
||||
default_retries: 3
|
||||
default_retry_delay_ms: 1000
|
||||
default_timeout_ms: 5000
|
||||
default_failure_threshold: 2
|
||||
|
||||
alerts:
|
||||
- name: "test-webhook"
|
||||
type: webhook
|
||||
url: "https://example.com/hook"
|
||||
method: POST
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
body_template: '{"msg": "{{monitor.name}}"}'
|
||||
|
||||
monitors:
|
||||
- name: "test-http"
|
||||
type: http
|
||||
target: "https://example.com"
|
||||
method: GET
|
||||
expected_status: 200
|
||||
alerts: ["test-webhook"]
|
||||
`;
|
||||
const config = parseConfig(yaml);
|
||||
|
||||
expect(config.settings.defaultRetries).toBe(3);
|
||||
expect(config.alerts).toHaveLength(1);
|
||||
expect(config.alerts[0].name).toBe('test-webhook');
|
||||
expect(config.monitors).toHaveLength(1);
|
||||
expect(config.monitors[0].name).toBe('test-http');
|
||||
});
|
||||
|
||||
it('applies defaults to monitors', () => {
|
||||
const yaml = `
|
||||
settings:
|
||||
default_retries: 5
|
||||
default_timeout_ms: 3000
|
||||
|
||||
monitors:
|
||||
- name: "minimal"
|
||||
type: http
|
||||
target: "https://example.com"
|
||||
`;
|
||||
const config = parseConfig(yaml);
|
||||
|
||||
expect(config.monitors[0].retries).toBe(5);
|
||||
expect(config.monitors[0].timeoutMs).toBe(3000);
|
||||
});
|
||||
|
||||
it('interpolates environment variables', () => {
|
||||
const yaml = `
|
||||
alerts:
|
||||
- name: "secure"
|
||||
type: webhook
|
||||
url: "https://example.com"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer \${TEST_SECRET}"
|
||||
body_template: "test"
|
||||
`;
|
||||
const config = parseConfig(yaml, { TEST_SECRET: 'my-secret-value' });
|
||||
|
||||
expect(config.alerts[0].headers.Authorization).toBe('Bearer my-secret-value');
|
||||
});
|
||||
|
||||
it('preserves unset env vars', () => {
|
||||
const yaml = `
|
||||
alerts:
|
||||
- name: "test"
|
||||
type: webhook
|
||||
url: "https://example.com/\${UNDEFINED_VAR}/path"
|
||||
method: POST
|
||||
body_template: "test"
|
||||
`;
|
||||
const config = parseConfig(yaml, {});
|
||||
|
||||
expect(config.alerts[0].url).toBe('https://example.com/${UNDEFINED_VAR}/path');
|
||||
});
|
||||
|
||||
it('defaults webhook method to POST', () => {
|
||||
const yaml = `
|
||||
alerts:
|
||||
- name: "test"
|
||||
type: webhook
|
||||
url: "https://example.com"
|
||||
method: POST
|
||||
body_template: "test"
|
||||
`;
|
||||
const config = parseConfig(yaml);
|
||||
|
||||
expect(config.alerts[0].method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should interpolate BASIC_AUTH_SECRET from env', () => {
|
||||
const yaml = `
|
||||
alerts:
|
||||
- name: "secure"
|
||||
type: webhook
|
||||
url: "https://example.com"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Basic \${BASIC_AUTH_SECRET}"
|
||||
body_template: "test"
|
||||
`;
|
||||
const config = parseConfig(yaml, { BASIC_AUTH_SECRET: 'dXNlcjpwYXNz' });
|
||||
|
||||
expect(config.alerts[0].headers.Authorization).toBe('Basic dXNlcjpwYXNz');
|
||||
});
|
||||
|
||||
it('parses monitor headers', () => {
|
||||
const yaml = `
|
||||
monitors:
|
||||
- name: "api-with-auth"
|
||||
type: http
|
||||
target: "https://api.example.com/health"
|
||||
headers:
|
||||
Authorization: "Bearer my-token"
|
||||
Accept: "application/json"
|
||||
`;
|
||||
const config = parseConfig(yaml);
|
||||
|
||||
expect(config.monitors[0].type).toBe('http');
|
||||
if (config.monitors[0].type === 'http') {
|
||||
expect(config.monitors[0].headers).toEqual({
|
||||
Authorization: 'Bearer my-token',
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults monitor headers to empty object', () => {
|
||||
const yaml = `
|
||||
monitors:
|
||||
- name: "no-headers"
|
||||
type: http
|
||||
target: "https://example.com"
|
||||
`;
|
||||
const config = parseConfig(yaml);
|
||||
|
||||
if (config.monitors[0].type === 'http') {
|
||||
expect(config.monitors[0].headers).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
it('interpolates env vars in monitor headers', () => {
|
||||
const yaml = `
|
||||
monitors:
|
||||
- name: "secure-api"
|
||||
type: http
|
||||
target: "https://api.example.com"
|
||||
headers:
|
||||
Authorization: "Bearer \${API_TOKEN}"
|
||||
`;
|
||||
const config = parseConfig(yaml, { API_TOKEN: 'secret-token' });
|
||||
|
||||
if (config.monitors[0].type === 'http') {
|
||||
expect(config.monitors[0].headers.Authorization).toBe('Bearer secret-token');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses alerts array with webhook type', () => {
|
||||
const yaml = `
|
||||
alerts:
|
||||
- name: test-webhook
|
||||
type: webhook
|
||||
url: https://example.com
|
||||
method: POST
|
||||
headers: {}
|
||||
body_template: 'test'
|
||||
monitors:
|
||||
- name: test
|
||||
type: http
|
||||
target: https://example.com
|
||||
alerts: [test-webhook]
|
||||
`;
|
||||
const config = parseConfig(yaml);
|
||||
expect(config.alerts).toHaveLength(1);
|
||||
expect(config.alerts[0].type).toBe('webhook');
|
||||
});
|
||||
|
||||
it('parses title from settings', () => {
|
||||
const yaml = `
|
||||
settings:
|
||||
title: "My Custom Status Page"
|
||||
default_retries: 3
|
||||
default_retry_delay_ms: 1000
|
||||
default_timeout_ms: 5000
|
||||
default_failure_threshold: 2
|
||||
|
||||
monitors:
|
||||
- name: "test-http"
|
||||
type: http
|
||||
target: "https://example.com"
|
||||
`;
|
||||
const config = parseConfig(yaml);
|
||||
|
||||
expect(config.settings.title).toBe('My Custom Status Page');
|
||||
});
|
||||
|
||||
it('makes title optional', () => {
|
||||
const yaml = `
|
||||
settings:
|
||||
default_retries: 3
|
||||
default_retry_delay_ms: 1000
|
||||
default_timeout_ms: 5000
|
||||
default_failure_threshold: 2
|
||||
|
||||
monitors:
|
||||
- name: "test-http"
|
||||
type: http
|
||||
target: "https://example.com"
|
||||
`;
|
||||
const config = parseConfig(yaml);
|
||||
|
||||
expect(config.settings.title).toBe('Atalaya Uptime Monitor');
|
||||
});
|
||||
|
||||
it('handles empty settings with default title', () => {
|
||||
const yaml = 'monitors: []';
|
||||
const config = parseConfig(yaml);
|
||||
expect(config.settings.title).toBe('Atalaya Uptime Monitor');
|
||||
});
|
||||
});
|
||||
113
src/config/config.ts
Normal file
113
src/config/config.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import yaml from 'js-yaml';
|
||||
import { isValidRegion } from '../utils/region.js';
|
||||
import type { Config, RawYamlConfig, Settings, Alert, Monitor } from './types.js';
|
||||
|
||||
const envVarRegex = /\$\{([^\}]+)\}/gv;
|
||||
|
||||
function interpolateEnv(content: string, envVars: Record<string, string | undefined> = {}): string {
|
||||
return content.replaceAll(envVarRegex, (match, varName: string) => {
|
||||
const value = envVars[varName];
|
||||
return value !== undefined && value !== '' ? value : match;
|
||||
});
|
||||
}
|
||||
|
||||
function applyDefaults(raw: RawYamlConfig): Config {
|
||||
const settings: Settings = {
|
||||
defaultRetries: raw.settings?.default_retries ?? 0,
|
||||
defaultRetryDelayMs: raw.settings?.default_retry_delay_ms ?? 0,
|
||||
defaultTimeoutMs: raw.settings?.default_timeout_ms ?? 0,
|
||||
defaultFailureThreshold: raw.settings?.default_failure_threshold ?? 0,
|
||||
title: raw.settings?.title ?? 'Atalaya Uptime Monitor',
|
||||
};
|
||||
|
||||
const alerts: Alert[] = [];
|
||||
if (raw.alerts) {
|
||||
for (const a of raw.alerts) {
|
||||
if (!a.name) {
|
||||
throw new Error(`Alert missing required field 'name': ${JSON.stringify(a)}`);
|
||||
}
|
||||
if (!a.type) {
|
||||
throw new Error(`Alert missing required fields: ${JSON.stringify(a)}`);
|
||||
}
|
||||
const type = a.type;
|
||||
if (type !== 'webhook') {
|
||||
throw new Error(`Unsupported alert type: ${type}`);
|
||||
}
|
||||
if (!a.url || !a.method || !a.body_template) {
|
||||
throw new Error(`Webhook alert missing required fields: ${a.name}`);
|
||||
}
|
||||
alerts.push({
|
||||
name: a.name,
|
||||
type: 'webhook',
|
||||
url: a.url,
|
||||
method: a.method ?? 'POST',
|
||||
headers: a.headers ?? {},
|
||||
bodyTemplate: a.body_template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const monitors: Monitor[] = (raw.monitors ?? []).map(m => {
|
||||
// Validate region if provided
|
||||
if (m.region && !isValidRegion(m.region)) {
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: 'invalid_region',
|
||||
region: m.region,
|
||||
monitor: m.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: m.name ?? '',
|
||||
target: m.target ?? '',
|
||||
timeoutMs: m.timeout_ms ?? settings.defaultTimeoutMs,
|
||||
retries: m.retries ?? settings.defaultRetries,
|
||||
retryDelayMs: m.retry_delay_ms ?? settings.defaultRetryDelayMs,
|
||||
failureThreshold: m.failure_threshold ?? settings.defaultFailureThreshold,
|
||||
alerts: m.alerts ?? [],
|
||||
region: m.region && isValidRegion(m.region) ? m.region : undefined,
|
||||
};
|
||||
|
||||
const type = (m.type as 'http' | 'tcp' | 'dns') ?? 'http';
|
||||
|
||||
switch (type) {
|
||||
case 'http': {
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
method: m.method ?? '',
|
||||
expectedStatus: m.expected_status ?? 0,
|
||||
headers: m.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tcp': {
|
||||
return { ...base, type };
|
||||
}
|
||||
|
||||
case 'dns': {
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
recordType: m.record_type ?? '',
|
||||
expectedValues: m.expected_values ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = type;
|
||||
throw new Error(`Unknown monitor type: ${String(_exhaustive)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { settings, alerts, monitors };
|
||||
}
|
||||
|
||||
export function parseConfig(yamlContent: string, env?: Record<string, string | undefined>): Config {
|
||||
const interpolated = interpolateEnv(yamlContent, env);
|
||||
const raw = yaml.load(interpolated) as RawYamlConfig;
|
||||
return applyDefaults(raw);
|
||||
}
|
||||
2
src/config/index.ts
Normal file
2
src/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseConfig } from './config.js';
|
||||
export type { Config, Settings, Alert, Monitor } from './types.js';
|
||||
89
src/config/types.ts
Normal file
89
src/config/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export type Settings = {
|
||||
defaultRetries: number;
|
||||
defaultRetryDelayMs: number;
|
||||
defaultTimeoutMs: number;
|
||||
defaultFailureThreshold: number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type AlertBase = { name: string };
|
||||
|
||||
export type WebhookAlert = AlertBase & {
|
||||
type: 'webhook';
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
bodyTemplate: string;
|
||||
};
|
||||
|
||||
export type Alert = WebhookAlert; // | EmailAlert | ...
|
||||
|
||||
interface MonitorBase {
|
||||
name: string;
|
||||
target: string;
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
failureThreshold: number;
|
||||
alerts: string[];
|
||||
region?: string; // Cloudflare region code for regional checks
|
||||
}
|
||||
|
||||
export interface HttpMonitor extends MonitorBase {
|
||||
type: 'http';
|
||||
method: string;
|
||||
expectedStatus: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TcpMonitor extends MonitorBase {
|
||||
type: 'tcp';
|
||||
}
|
||||
|
||||
export interface DnsMonitor extends MonitorBase {
|
||||
type: 'dns';
|
||||
recordType: string;
|
||||
expectedValues: string[];
|
||||
}
|
||||
|
||||
export type Monitor = HttpMonitor | TcpMonitor | DnsMonitor;
|
||||
|
||||
export type Config = {
|
||||
settings: Settings;
|
||||
alerts: Alert[];
|
||||
monitors: Monitor[];
|
||||
};
|
||||
|
||||
export type RawYamlConfig = {
|
||||
settings?: {
|
||||
default_retries?: number;
|
||||
default_retry_delay_ms?: number;
|
||||
default_timeout_ms?: number;
|
||||
default_failure_threshold?: number;
|
||||
title?: string; // New optional field
|
||||
};
|
||||
alerts?: Array<{
|
||||
type?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body_template?: string;
|
||||
}>;
|
||||
monitors?: Array<{
|
||||
name?: string;
|
||||
type?: string;
|
||||
target?: string;
|
||||
method?: string;
|
||||
expected_status?: number;
|
||||
headers?: Record<string, string>;
|
||||
record_type?: string;
|
||||
expected_values?: string[];
|
||||
timeout_ms?: number;
|
||||
retries?: number;
|
||||
retry_delay_ms?: number;
|
||||
failure_threshold?: number;
|
||||
alerts?: string[];
|
||||
region?: string; // Cloudflare region code for regional checks
|
||||
}>;
|
||||
};
|
||||
155
src/db.test.ts
Normal file
155
src/db.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { getMonitorStates, writeCheckResults, updateMonitorStates, recordAlert } from './db.js';
|
||||
|
||||
function createMockDatabase() {
|
||||
const mockRun = vi.fn().mockResolvedValue({});
|
||||
const mockAll = vi.fn().mockResolvedValue({ results: [] });
|
||||
const mockBind = vi.fn().mockReturnThis();
|
||||
|
||||
const mockStmt = {
|
||||
bind: mockBind,
|
||||
run: mockRun,
|
||||
all: mockAll,
|
||||
};
|
||||
|
||||
const mockPrepare = vi.fn().mockReturnValue(mockStmt);
|
||||
const mockBatch = vi.fn().mockResolvedValue([]);
|
||||
|
||||
type MockDb = D1Database & {
|
||||
_mockStmt: typeof mockStmt;
|
||||
_mockBind: typeof mockBind;
|
||||
_mockAll: typeof mockAll;
|
||||
_mockRun: typeof mockRun;
|
||||
};
|
||||
|
||||
return {
|
||||
prepare: mockPrepare,
|
||||
batch: mockBatch,
|
||||
_mockStmt: mockStmt,
|
||||
_mockBind: mockBind,
|
||||
_mockAll: mockAll,
|
||||
_mockRun: mockRun,
|
||||
} as unknown as MockDb;
|
||||
}
|
||||
|
||||
describe('getMonitorStates', () => {
|
||||
it('returns empty array when no states exist', async () => {
|
||||
const db = createMockDatabase();
|
||||
const result = await getMonitorStates(db);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns monitor states from database', async () => {
|
||||
const db = createMockDatabase();
|
||||
const mockStates = [
|
||||
{
|
||||
monitor_name: 'test-monitor',
|
||||
current_status: 'up',
|
||||
consecutive_failures: 0,
|
||||
last_status_change: 1_700_000_000,
|
||||
last_checked: 1_700_001_000,
|
||||
},
|
||||
];
|
||||
db._mockAll.mockResolvedValue({ results: mockStates });
|
||||
|
||||
const result = await getMonitorStates(db);
|
||||
expect(result).toEqual(mockStates);
|
||||
expect(db.prepare).toHaveBeenCalledWith(expect.stringContaining('SELECT'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeCheckResults', () => {
|
||||
it('does nothing when writes array is empty', async () => {
|
||||
const db = createMockDatabase();
|
||||
await writeCheckResults(db, []);
|
||||
expect(db.batch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batches writes to database', async () => {
|
||||
const db = createMockDatabase();
|
||||
const writes = [
|
||||
{
|
||||
monitorName: 'test-monitor',
|
||||
checkedAt: 1_700_000_000,
|
||||
status: 'up',
|
||||
responseTimeMs: 150,
|
||||
errorMessage: '',
|
||||
attempts: 1,
|
||||
},
|
||||
{
|
||||
monitorName: 'test-monitor-2',
|
||||
checkedAt: 1_700_000_000,
|
||||
status: 'down',
|
||||
responseTimeMs: 5000,
|
||||
errorMessage: 'Timeout',
|
||||
attempts: 3,
|
||||
},
|
||||
];
|
||||
|
||||
await writeCheckResults(db, writes);
|
||||
expect(db.prepare).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO check_results'));
|
||||
expect(db.batch).toHaveBeenCalledTimes(1);
|
||||
expect(db._mockBind).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMonitorStates', () => {
|
||||
it('does nothing when updates array is empty', async () => {
|
||||
const db = createMockDatabase();
|
||||
await updateMonitorStates(db, []);
|
||||
expect(db.batch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batches state updates to database', async () => {
|
||||
const db = createMockDatabase();
|
||||
const updates = [
|
||||
{
|
||||
monitorName: 'test-monitor',
|
||||
currentStatus: 'down',
|
||||
consecutiveFailures: 3,
|
||||
lastStatusChange: 1_700_000_000,
|
||||
lastChecked: 1_700_001_000,
|
||||
},
|
||||
];
|
||||
|
||||
await updateMonitorStates(db, updates);
|
||||
expect(db.prepare).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO monitor_state'));
|
||||
expect(db.prepare).toHaveBeenCalledWith(expect.stringContaining('ON CONFLICT'));
|
||||
expect(db.batch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordAlert', () => {
|
||||
it('inserts alert record', async () => {
|
||||
const db = createMockDatabase();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
|
||||
|
||||
await recordAlert(db, 'test-monitor', 'down', 'slack', true);
|
||||
|
||||
expect(db.prepare).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO alerts'));
|
||||
expect(db._mockBind).toHaveBeenCalledWith(
|
||||
'test-monitor',
|
||||
'down',
|
||||
expect.any(Number),
|
||||
'slack',
|
||||
1
|
||||
);
|
||||
expect(db._mockRun).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('records failure correctly', async () => {
|
||||
const db = createMockDatabase();
|
||||
await recordAlert(db, 'test-monitor', 'recovery', 'discord', false);
|
||||
|
||||
expect(db._mockBind).toHaveBeenCalledWith(
|
||||
'test-monitor',
|
||||
'recovery',
|
||||
expect.any(Number),
|
||||
'discord',
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
90
src/db.ts
Normal file
90
src/db.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { MonitorState, DbWrite, StateUpdate } from './types.js';
|
||||
|
||||
const batchLimit = 100;
|
||||
|
||||
function chunkArray<T>(array: T[], chunkSize: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += chunkSize) {
|
||||
chunks.push(array.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export async function getMonitorStates(database: D1Database): Promise<MonitorState[]> {
|
||||
const result = await database
|
||||
.prepare(
|
||||
'SELECT monitor_name, current_status, consecutive_failures, last_status_change, last_checked FROM monitor_state WHERE 1=?'
|
||||
)
|
||||
.bind(1)
|
||||
.all<MonitorState>();
|
||||
|
||||
return result.results || [];
|
||||
}
|
||||
|
||||
export async function writeCheckResults(database: D1Database, writes: DbWrite[]): Promise<void> {
|
||||
if (writes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stmt = database.prepare(
|
||||
'INSERT INTO check_results (monitor_name, checked_at, status, response_time_ms, error_message, attempts) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
const batch = writes.map(w =>
|
||||
stmt.bind(w.monitorName, w.checkedAt, w.status, w.responseTimeMs, w.errorMessage, w.attempts)
|
||||
);
|
||||
|
||||
const chunks = chunkArray(batch, batchLimit);
|
||||
for (const chunk of chunks) {
|
||||
await database.batch(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMonitorStates(
|
||||
database: D1Database,
|
||||
updates: StateUpdate[]
|
||||
): Promise<void> {
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stmt =
|
||||
database.prepare(`INSERT INTO monitor_state (monitor_name, current_status, consecutive_failures, last_status_change, last_checked)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(monitor_name) DO UPDATE SET
|
||||
current_status = excluded.current_status,
|
||||
consecutive_failures = excluded.consecutive_failures,
|
||||
last_status_change = excluded.last_status_change,
|
||||
last_checked = excluded.last_checked`);
|
||||
|
||||
const batch = updates.map(u =>
|
||||
stmt.bind(
|
||||
u.monitorName,
|
||||
u.currentStatus,
|
||||
u.consecutiveFailures,
|
||||
u.lastStatusChange,
|
||||
u.lastChecked
|
||||
)
|
||||
);
|
||||
|
||||
const chunks = chunkArray(batch, batchLimit);
|
||||
for (const chunk of chunks) {
|
||||
await database.batch(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordAlert(
|
||||
database: D1Database,
|
||||
monitorName: string,
|
||||
alertType: string,
|
||||
alertName: string,
|
||||
success: boolean
|
||||
): Promise<void> {
|
||||
await database
|
||||
.prepare(
|
||||
'INSERT INTO alerts (monitor_name, alert_type, sent_at, alert_name, success) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(monitorName, alertType, Math.floor(Date.now() / 1000), alertName, success ? 1 : 0)
|
||||
.run();
|
||||
}
|
||||
426
src/index.test.ts
Normal file
426
src/index.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { interpolateSecrets } from './utils/interpolate.js';
|
||||
|
||||
// Mock Cloudflare-specific modules that can't be resolved in Node.js
|
||||
vi.mock('cloudflare:sockets', () => ({
|
||||
connect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('cloudflare:workers', () => ({
|
||||
DurableObject: class {},
|
||||
}));
|
||||
|
||||
// Mock the auth module
|
||||
vi.mock('../status-page/src/lib/auth.js', () => ({
|
||||
checkAuth: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock the Astro SSR app (build artifact won't exist during tests)
|
||||
const astroFetchMock = vi.fn().mockResolvedValue(new Response('<html>OK</html>', { status: 200 }));
|
||||
vi.mock('../status-page/dist/server/index.mjs', () => ({
|
||||
default: {
|
||||
fetch: astroFetchMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock caches API
|
||||
const mockCaches = {
|
||||
default: {
|
||||
match: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error - Adding caches to global for testing
|
||||
global.caches = mockCaches;
|
||||
|
||||
describe('worker fetch handler', () => {
|
||||
async function getWorker() {
|
||||
const mod = await import('./index.js');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
const mockEnv = {
|
||||
DB: {},
|
||||
ASSETS: {
|
||||
fetch: vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 })),
|
||||
},
|
||||
} as any;
|
||||
|
||||
const mockCtx = {
|
||||
waitUntil: vi.fn(),
|
||||
passThroughOnException: vi.fn(),
|
||||
props: vi.fn(),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
it('should delegate to Astro SSR for GET /', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/');
|
||||
const response = await worker.fetch(request, mockEnv, mockCtx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 401 when auth fails', async () => {
|
||||
const { checkAuth } = await import('../status-page/src/lib/auth.js');
|
||||
vi.mocked(checkAuth).mockResolvedValueOnce(
|
||||
new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'Basic realm="Status Page"' },
|
||||
})
|
||||
);
|
||||
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/');
|
||||
const response = await worker.fetch(request, mockEnv, mockCtx);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
describe('caching', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCaches.default.match.mockReset();
|
||||
mockCaches.default.put.mockReset();
|
||||
});
|
||||
|
||||
it('should cache response when STATUS_PUBLIC is true', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/');
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
// First request - cache miss
|
||||
mockCaches.default.match.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await worker.fetch(request, envWithPublic, mockCtx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Cache-Control')).toBe('public, max-age=60');
|
||||
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
|
||||
expect(mockCaches.default.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return cached response when available', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/');
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
// Cache hit
|
||||
const cachedResponse = new Response('<html>Cached</html>', {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': 'public, max-age=60' },
|
||||
});
|
||||
mockCaches.default.match.mockResolvedValueOnce(cachedResponse);
|
||||
|
||||
const response = await worker.fetch(request, envWithPublic, mockCtx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Cache-Control')).toBe('public, max-age=60');
|
||||
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
|
||||
expect(mockCaches.default.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not cache when STATUS_PUBLIC is not true', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/');
|
||||
const envWithoutPublic = { ...mockEnv, STATUS_PUBLIC: 'false' };
|
||||
|
||||
const response = await worker.fetch(request, envWithoutPublic, mockCtx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Cache-Control')).toBeNull();
|
||||
expect(mockCaches.default.match).not.toHaveBeenCalled();
|
||||
expect(mockCaches.default.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not cache when STATUS_PUBLIC is undefined', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/');
|
||||
const envWithoutPublic = { ...mockEnv };
|
||||
delete envWithoutPublic.STATUS_PUBLIC;
|
||||
|
||||
const response = await worker.fetch(request, envWithoutPublic, mockCtx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Cache-Control')).toBeNull();
|
||||
expect(mockCaches.default.match).not.toHaveBeenCalled();
|
||||
expect(mockCaches.default.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not cache non-GET requests', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/', { method: 'POST' });
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
const response = await worker.fetch(request, envWithPublic, mockCtx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Cache-Control')).toBeNull();
|
||||
expect(mockCaches.default.match).not.toHaveBeenCalled();
|
||||
expect(mockCaches.default.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not cache error responses', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/');
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
// Mock Astro to return error
|
||||
astroFetchMock.mockResolvedValueOnce(new Response('Error', { status: 500 }));
|
||||
|
||||
// First request - cache miss
|
||||
mockCaches.default.match.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await worker.fetch(request, envWithPublic, mockCtx);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.headers.get('Cache-Control')).toBeNull();
|
||||
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
|
||||
expect(mockCaches.default.put).not.toHaveBeenCalled();
|
||||
|
||||
// Reset mock for other tests
|
||||
astroFetchMock.mockResolvedValue(new Response('<html>OK</html>', { status: 200 }));
|
||||
});
|
||||
|
||||
it('should not cache API endpoints', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/api/status');
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
const response = await worker.fetch(request, envWithPublic, mockCtx);
|
||||
|
||||
// API endpoint returns JSON, not HTML
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
expect(response.headers.get('Cache-Control')).toBeNull();
|
||||
expect(mockCaches.default.match).not.toHaveBeenCalled();
|
||||
expect(mockCaches.default.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should normalize cache key by removing query parameters', async () => {
|
||||
const worker = await getWorker();
|
||||
const request1 = new Request('https://example.com/?t=1234567890');
|
||||
const request2 = new Request('https://example.com/?cache=bust&v=2.0');
|
||||
const request3 = new Request('https://example.com/');
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
// First request with query params - cache miss
|
||||
mockCaches.default.match.mockResolvedValueOnce(null);
|
||||
await worker.fetch(request1, envWithPublic, mockCtx);
|
||||
|
||||
// Get the cache key that was used
|
||||
const cacheKey1 = mockCaches.default.match.mock.calls[0][0];
|
||||
expect(cacheKey1.url).toBe('https://example.com/');
|
||||
|
||||
// Reset mock for second request
|
||||
mockCaches.default.match.mockReset();
|
||||
mockCaches.default.put.mockReset();
|
||||
|
||||
// Second request with different query params - should use same normalized cache key
|
||||
mockCaches.default.match.mockResolvedValueOnce(null);
|
||||
await worker.fetch(request2, envWithPublic, mockCtx);
|
||||
|
||||
const cacheKey2 = mockCaches.default.match.mock.calls[0][0];
|
||||
expect(cacheKey2.url).toBe('https://example.com/');
|
||||
|
||||
// Reset mock for third request
|
||||
mockCaches.default.match.mockReset();
|
||||
mockCaches.default.put.mockReset();
|
||||
|
||||
// Third request without query params - should use same normalized cache key
|
||||
mockCaches.default.match.mockResolvedValueOnce(null);
|
||||
await worker.fetch(request3, envWithPublic, mockCtx);
|
||||
|
||||
const cacheKey3 = mockCaches.default.match.mock.calls[0][0];
|
||||
expect(cacheKey3.url).toBe('https://example.com/');
|
||||
});
|
||||
|
||||
it('should normalize cache key by removing hash fragment', async () => {
|
||||
const worker = await getWorker();
|
||||
const request = new Request('https://example.com/#section1');
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
mockCaches.default.match.mockResolvedValueOnce(null);
|
||||
await worker.fetch(request, envWithPublic, mockCtx);
|
||||
|
||||
const cacheKey = mockCaches.default.match.mock.calls[0][0];
|
||||
expect(cacheKey.url).toBe('https://example.com/');
|
||||
});
|
||||
|
||||
it('should use normalized headers in cache key (ignore cookies)', async () => {
|
||||
const worker = await getWorker();
|
||||
|
||||
// Request with cookies
|
||||
const requestWithCookies = new Request('https://example.com/', {
|
||||
headers: {
|
||||
Cookie: 'session=abc123; user=john',
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
},
|
||||
});
|
||||
|
||||
// Request without cookies
|
||||
const requestWithoutCookies = new Request('https://example.com/', {
|
||||
headers: {
|
||||
'User-Agent': 'Different-Browser',
|
||||
},
|
||||
});
|
||||
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
// First request with cookies
|
||||
mockCaches.default.match.mockResolvedValueOnce(null);
|
||||
await worker.fetch(requestWithCookies, envWithPublic, mockCtx);
|
||||
|
||||
const cacheKey1 = mockCaches.default.match.mock.calls[0][0];
|
||||
// Should not have Cookie header in cache key
|
||||
expect(cacheKey1.headers.get('Cookie')).toBeNull();
|
||||
|
||||
// Reset mock for second request
|
||||
mockCaches.default.match.mockReset();
|
||||
mockCaches.default.put.mockReset();
|
||||
|
||||
// Second request without cookies - should use same cache key
|
||||
mockCaches.default.match.mockResolvedValueOnce(null);
|
||||
await worker.fetch(requestWithoutCookies, envWithPublic, mockCtx);
|
||||
|
||||
const cacheKey2 = mockCaches.default.match.mock.calls[0][0];
|
||||
expect(cacheKey2.headers.get('Cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('should cache hit for same normalized URL regardless of query params', async () => {
|
||||
const worker = await getWorker();
|
||||
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
|
||||
|
||||
// First request with query params
|
||||
const request1 = new Request('https://example.com/?cache=bust');
|
||||
const cachedResponse = new Response('<html>Cached</html>', {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': 'public, max-age=60' },
|
||||
});
|
||||
|
||||
// Cache hit for normalized URL
|
||||
mockCaches.default.match.mockResolvedValueOnce(cachedResponse);
|
||||
|
||||
const response1 = await worker.fetch(request1, envWithPublic, mockCtx);
|
||||
expect(response1.status).toBe(200);
|
||||
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Get the cache key that was used
|
||||
const cacheKey1 = mockCaches.default.match.mock.calls[0][0];
|
||||
expect(cacheKey1.url).toBe('https://example.com/');
|
||||
|
||||
// Reset mock
|
||||
mockCaches.default.match.mockReset();
|
||||
|
||||
// Second request with different query params - should also be cache hit
|
||||
const request2 = new Request('https://example.com/?t=123456');
|
||||
mockCaches.default.match.mockResolvedValueOnce(cachedResponse);
|
||||
|
||||
const response2 = await worker.fetch(request2, envWithPublic, mockCtx);
|
||||
expect(response2.status).toBe(200);
|
||||
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
|
||||
|
||||
const cacheKey2 = mockCaches.default.match.mock.calls[0][0];
|
||||
expect(cacheKey2.url).toBe('https://example.com/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateSecrets', () => {
|
||||
it('should interpolate any secret from env object', () => {
|
||||
const configYaml = 'auth: ${MY_SECRET}';
|
||||
const env = {
|
||||
MY_SECRET: 'secret123',
|
||||
};
|
||||
|
||||
const result = interpolateSecrets(configYaml, env);
|
||||
expect(result).toBe('auth: secret123');
|
||||
});
|
||||
|
||||
it('should handle multiple interpolations', () => {
|
||||
const configYaml = `
|
||||
auth: \${API_KEY}
|
||||
url: \${API_URL}
|
||||
token: \${ACCESS_TOKEN}
|
||||
`;
|
||||
const env = {
|
||||
API_KEY: 'key123',
|
||||
API_URL: 'https://api.example.com',
|
||||
ACCESS_TOKEN: 'token456',
|
||||
};
|
||||
|
||||
const result = interpolateSecrets(configYaml, env);
|
||||
expect(result).toContain('auth: key123');
|
||||
expect(result).toContain('url: https://api.example.com');
|
||||
expect(result).toContain('token: token456');
|
||||
});
|
||||
|
||||
it('should leave unmatched variables as-is', () => {
|
||||
const configYaml = 'auth: ${UNKNOWN_SECRET}';
|
||||
const env = {
|
||||
MY_SECRET: 'secret123',
|
||||
};
|
||||
|
||||
const result = interpolateSecrets(configYaml, env);
|
||||
|
||||
expect(result).toBe('auth: ${UNKNOWN_SECRET}');
|
||||
});
|
||||
|
||||
it('should handle empty env values', () => {
|
||||
const configYaml = 'auth: ${EMPTY_SECRET}';
|
||||
const env = {
|
||||
EMPTY_SECRET: '',
|
||||
};
|
||||
|
||||
const result = interpolateSecrets(configYaml, env);
|
||||
expect(result).toBe('auth: ');
|
||||
});
|
||||
|
||||
it('should handle undefined env values', () => {
|
||||
const configYaml = 'auth: ${UNDEFINED_SECRET}';
|
||||
const env = {
|
||||
// No UNDEFINED_SECRET key
|
||||
};
|
||||
|
||||
const result = interpolateSecrets(configYaml, env);
|
||||
|
||||
expect(result).toBe('auth: ${UNDEFINED_SECRET}');
|
||||
});
|
||||
|
||||
it('should handle complex YAML with mixed content', () => {
|
||||
const configYaml = `
|
||||
monitors:
|
||||
- name: API Check
|
||||
type: http
|
||||
url: \${API_URL}/health
|
||||
headers:
|
||||
Authorization: Bearer \${API_TOKEN}
|
||||
expectedStatus: 200
|
||||
notifications:
|
||||
- type: webhook
|
||||
url: \${WEBHOOK_URL}
|
||||
auth: \${WEBHOOK_AUTH}
|
||||
`;
|
||||
const env = {
|
||||
API_URL: 'https://api.example.com',
|
||||
API_TOKEN: 'token123',
|
||||
WEBHOOK_URL: 'https://hooks.example.com',
|
||||
WEBHOOK_AUTH: 'basic auth',
|
||||
};
|
||||
|
||||
const result = interpolateSecrets(configYaml, env);
|
||||
expect(result).toContain('url: https://api.example.com/health');
|
||||
expect(result).toContain('Authorization: Bearer token123');
|
||||
expect(result).toContain('url: https://hooks.example.com');
|
||||
expect(result).toContain('auth: basic auth');
|
||||
});
|
||||
|
||||
it('should handle special characters in variable names', () => {
|
||||
const configYaml = 'auth: ${MY-SECRET_KEY}';
|
||||
const env = {
|
||||
'MY-SECRET_KEY': 'value123',
|
||||
};
|
||||
|
||||
const result = interpolateSecrets(configYaml, env);
|
||||
expect(result).toBe('auth: value123');
|
||||
});
|
||||
});
|
||||
297
src/index.ts
Normal file
297
src/index.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { checkAuth } from '../status-page/src/lib/auth.js';
|
||||
import { handleAggregation } from './aggregation.js';
|
||||
import { executeDnsCheck } from './checks/dns.js';
|
||||
import { executeHttpCheck } from './checks/http.js';
|
||||
import { executeTcpCheck } from './checks/tcp.js';
|
||||
import { parseConfig } from './config/index.js';
|
||||
import { prepareChecks } from './checker/index.js';
|
||||
import { processResults } from './processor/index.js';
|
||||
import { formatWebhookPayload } from './alert/index.js';
|
||||
import { getStatusApiData } from './api/status.js';
|
||||
import { getMonitorStates, writeCheckResults, updateMonitorStates, recordAlert } from './db.js';
|
||||
import { interpolateSecrets } from './utils/interpolate.js';
|
||||
import type { Env } from './types.js';
|
||||
import type { CheckRequest } from './checker/types.js';
|
||||
import type { CheckResult } from './processor/types.js';
|
||||
import type { Config } from './config/types.js';
|
||||
|
||||
const worker = {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/api/status') {
|
||||
try {
|
||||
const configYaml = interpolateSecrets(env.MONITORS_CONFIG, env);
|
||||
const config = parseConfig(configYaml);
|
||||
const data = await getStatusApiData(env.DB, config);
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Status API error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auth check for all non-API routes
|
||||
const authResponse = await checkAuth(request, env);
|
||||
if (authResponse) {
|
||||
return authResponse;
|
||||
}
|
||||
|
||||
// Only cache GET requests when status page is public
|
||||
let cacheKey: Request | undefined;
|
||||
if (request.method === 'GET' && env.STATUS_PUBLIC === 'true') {
|
||||
// Create normalized cache key to prevent bypass via query params, headers, or cookies
|
||||
const normalizedUrl = new URL(url);
|
||||
normalizedUrl.search = ''; // Remove query parameters
|
||||
normalizedUrl.hash = ''; // Remove hash fragment
|
||||
cacheKey = new Request(normalizedUrl.toString());
|
||||
|
||||
const cachedResponse = await caches.default.match(cacheKey);
|
||||
if (cachedResponse) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: 'cache_hit',
|
||||
url: url.toString(),
|
||||
normalizedUrl: normalizedUrl.toString(),
|
||||
})
|
||||
);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: 'cache_miss',
|
||||
url: url.toString(),
|
||||
normalizedUrl: normalizedUrl.toString(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Try static assets first (CSS, JS, favicon, etc.)
|
||||
if (env.ASSETS) {
|
||||
const assetResponse = await env.ASSETS.fetch(request);
|
||||
if (assetResponse.status !== 404) {
|
||||
return assetResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to Astro SSR app for page rendering
|
||||
try {
|
||||
const astroMod: { default: ExportedHandler } = await import(
|
||||
// @ts-expect-error -- build artifact, resolved at bundle time
|
||||
'../status-page/dist/server/index.mjs'
|
||||
);
|
||||
if (astroMod.default.fetch) {
|
||||
const response = await astroMod.default.fetch(
|
||||
request as unknown as Request<unknown, IncomingRequestCfProperties>,
|
||||
env,
|
||||
ctx
|
||||
);
|
||||
|
||||
// Cache successful responses when status page is public
|
||||
if (
|
||||
request.method === 'GET' &&
|
||||
env.STATUS_PUBLIC === 'true' &&
|
||||
response.status === 200 &&
|
||||
cacheKey
|
||||
) {
|
||||
const responseWithCache = new Response(response.body, response);
|
||||
responseWithCache.headers.set('Cache-Control', 'public, max-age=60');
|
||||
|
||||
ctx.waitUntil(caches.default.put(cacheKey, responseWithCache.clone()));
|
||||
|
||||
return responseWithCache;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ event: 'astro_ssr_error', error: String(error) }));
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
async scheduled(event: ScheduledController, env: Env, _: ExecutionContext): Promise<void> {
|
||||
if (event.cron === '0 * * * *') {
|
||||
await handleAggregation(env);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const configYaml = interpolateSecrets(env.MONITORS_CONFIG, env);
|
||||
const config = parseConfig(configYaml);
|
||||
|
||||
const checks = prepareChecks(config);
|
||||
|
||||
if (checks.length === 0) {
|
||||
console.warn(JSON.stringify({ event: 'no_monitors_configured' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await executeAllChecks(checks, env);
|
||||
|
||||
const states = await getMonitorStates(env.DB);
|
||||
const actions = processResults(results, states, config);
|
||||
|
||||
await writeCheckResults(env.DB, actions.dbWrites);
|
||||
await updateMonitorStates(env.DB, actions.stateUpdates);
|
||||
|
||||
await Promise.all(
|
||||
actions.alerts.map(async alert => {
|
||||
const success = await sendWebhook(alert, config);
|
||||
await recordAlert(env.DB, alert.monitorName, alert.alertType, alert.alertName, success);
|
||||
})
|
||||
);
|
||||
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: 'scheduled_complete',
|
||||
checks: checks.length,
|
||||
alerts: actions.alerts.length,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: 'scheduled_error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
|
||||
export default worker;
|
||||
|
||||
async function executeAllChecks(checks: CheckRequest[], env: Env): Promise<CheckResult[]> {
|
||||
const promises = checks.map(async check => executeCheck(check, env));
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
async function executeCheck(check: CheckRequest, env: Env): Promise<CheckResult> {
|
||||
// If region is specified and we have Durable Object binding, run check from that region
|
||||
if (check.region && env.REGIONAL_CHECKER_DO) {
|
||||
try {
|
||||
console.warn(
|
||||
JSON.stringify({ event: 'regional_check_start', monitor: check.name, region: check.region })
|
||||
);
|
||||
|
||||
// Create Durable Object ID from monitor name
|
||||
const doId = env.REGIONAL_CHECKER_DO.idFromName(check.name);
|
||||
const doStub = env.REGIONAL_CHECKER_DO.get(doId, {
|
||||
locationHint: check.region as DurableObjectLocationHint,
|
||||
});
|
||||
|
||||
type RegionalCheckerStub = {
|
||||
runCheck: (check: CheckRequest) => Promise<CheckResult>;
|
||||
kill: () => Promise<void>;
|
||||
};
|
||||
|
||||
const typedStub = doStub as unknown as RegionalCheckerStub;
|
||||
const result = await typedStub.runCheck(check);
|
||||
|
||||
// Kill the Durable Object to save resources
|
||||
try {
|
||||
await typedStub.kill();
|
||||
} catch {
|
||||
// Ignore kill errors - Durable Object will be garbage collected
|
||||
}
|
||||
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: 'regional_check_complete',
|
||||
monitor: check.name,
|
||||
region: check.region,
|
||||
status: result.status,
|
||||
})
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: 'regional_check_error',
|
||||
monitor: check.name,
|
||||
region: check.region,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
|
||||
// Fall back to local check
|
||||
console.warn(JSON.stringify({ event: 'regional_check_fallback', monitor: check.name }));
|
||||
return executeLocalCheck(check);
|
||||
}
|
||||
} else {
|
||||
// Run check locally (current behavior)
|
||||
return executeLocalCheck(check);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeLocalCheck(check: CheckRequest): Promise<CheckResult> {
|
||||
console.warn(JSON.stringify({ event: 'local_check_start', monitor: check.name }));
|
||||
switch (check.type) {
|
||||
case 'http': {
|
||||
return executeHttpCheck(check);
|
||||
}
|
||||
|
||||
case 'tcp': {
|
||||
return executeTcpCheck(check);
|
||||
}
|
||||
|
||||
case 'dns': {
|
||||
return executeDnsCheck(check);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWebhook(
|
||||
alert: {
|
||||
alertName: string;
|
||||
monitorName: string;
|
||||
alertType: string;
|
||||
error: string;
|
||||
timestamp: number;
|
||||
},
|
||||
config: Config
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const payload = formatWebhookPayload({
|
||||
alertName: alert.alertName,
|
||||
monitorName: alert.monitorName,
|
||||
alertType: alert.alertType,
|
||||
error: alert.error,
|
||||
timestamp: alert.timestamp,
|
||||
config,
|
||||
});
|
||||
|
||||
if (!payload.url) {
|
||||
console.error(JSON.stringify({ event: 'webhook_not_found', alert: alert.alertName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(payload.url, {
|
||||
method: payload.method,
|
||||
headers: payload.headers,
|
||||
body: payload.body,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error_) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: 'webhook_failed',
|
||||
alert: alert.alertName,
|
||||
error: error_ instanceof Error ? error_.message : String(error_),
|
||||
})
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export { RegionalChecker } from './regional/checker.js';
|
||||
62
src/integration.test.ts
Normal file
62
src/integration.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { interpolateSecrets } from './utils/interpolate.js';
|
||||
import { parseConfig } from './config/index.js';
|
||||
|
||||
describe('integration: secret interpolation with config parsing', () => {
|
||||
it('should parse config with interpolated secret', () => {
|
||||
const configYaml = `
|
||||
alerts:
|
||||
- name: "ntfy"
|
||||
type: webhook
|
||||
url: "https://example.com"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Basic \${BASIC_AUTH}"
|
||||
Content-Type: application/json
|
||||
body_template: "test"
|
||||
|
||||
monitors:
|
||||
- name: "test"
|
||||
type: http
|
||||
target: "https://example.com"
|
||||
`;
|
||||
|
||||
const env = {
|
||||
DB: {} as any,
|
||||
MONITORS_CONFIG: '',
|
||||
BASIC_AUTH: 'dXNlcjpwYXNz',
|
||||
};
|
||||
|
||||
const interpolated = interpolateSecrets(configYaml, env as any);
|
||||
const config = parseConfig(interpolated);
|
||||
|
||||
expect(config.alerts).toHaveLength(1);
|
||||
expect(config.alerts[0].headers.Authorization).toBe('Basic dXNlcjpwYXNz');
|
||||
expect(config.monitors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle config without secrets', () => {
|
||||
const configYaml = `
|
||||
alerts:
|
||||
- name: "simple"
|
||||
type: webhook
|
||||
url: "https://example.com"
|
||||
method: POST
|
||||
headers: {}
|
||||
body_template: "test"
|
||||
|
||||
monitors: []
|
||||
`;
|
||||
|
||||
const env = {
|
||||
DB: {} as any,
|
||||
MONITORS_CONFIG: '',
|
||||
};
|
||||
|
||||
const interpolated = interpolateSecrets(configYaml, env as any);
|
||||
const config = parseConfig(interpolated);
|
||||
|
||||
expect(config.alerts).toHaveLength(1);
|
||||
expect(config.monitors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
9
src/processor/index.ts
Normal file
9
src/processor/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { processResults } from './processor.js';
|
||||
export type {
|
||||
CheckResult,
|
||||
MonitorState,
|
||||
Actions,
|
||||
DbWrite,
|
||||
AlertCall,
|
||||
StateUpdate,
|
||||
} from './types.js';
|
||||
294
src/processor/processor.test.ts
Normal file
294
src/processor/processor.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Config } from '../config/types.js';
|
||||
import { processResults } from './processor.js';
|
||||
import type { CheckResult, MonitorState } from './types.js';
|
||||
|
||||
describe('processResults', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-31T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('triggers down webhook when threshold met', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
name: 'alert',
|
||||
url: 'https://example.com',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
bodyTemplate: '',
|
||||
},
|
||||
],
|
||||
monitors: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: ['alert'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results: CheckResult[] = [
|
||||
{
|
||||
name: 'test',
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'timeout',
|
||||
attempts: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const states: MonitorState[] = [
|
||||
{
|
||||
monitor_name: 'test',
|
||||
current_status: 'up',
|
||||
consecutive_failures: 1,
|
||||
last_status_change: 0,
|
||||
last_checked: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const actions = processResults(results, states, config);
|
||||
|
||||
expect(actions.stateUpdates).toHaveLength(1);
|
||||
expect(actions.stateUpdates[0].consecutiveFailures).toBe(2);
|
||||
expect(actions.alerts).toHaveLength(1);
|
||||
expect(actions.alerts[0].alertType).toBe('down');
|
||||
});
|
||||
|
||||
it('triggers recovery webhook on up after down', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
name: 'alert',
|
||||
url: 'https://example.com',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
bodyTemplate: '',
|
||||
},
|
||||
],
|
||||
monitors: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: ['alert'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results: CheckResult[] = [
|
||||
{
|
||||
name: 'test',
|
||||
status: 'up',
|
||||
responseTimeMs: 150,
|
||||
error: '',
|
||||
attempts: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const states: MonitorState[] = [
|
||||
{
|
||||
monitor_name: 'test',
|
||||
current_status: 'down',
|
||||
consecutive_failures: 3,
|
||||
last_status_change: 0,
|
||||
last_checked: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const actions = processResults(results, states, config);
|
||||
|
||||
expect(actions.alerts).toHaveLength(1);
|
||||
expect(actions.alerts[0].alertType).toBe('recovery');
|
||||
});
|
||||
|
||||
it('does not trigger webhook when below threshold', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 3,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 3,
|
||||
alerts: ['alert'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results: CheckResult[] = [
|
||||
{
|
||||
name: 'test',
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'timeout',
|
||||
attempts: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const states: MonitorState[] = [
|
||||
{
|
||||
monitor_name: 'test',
|
||||
current_status: 'up',
|
||||
consecutive_failures: 1,
|
||||
last_status_change: 0,
|
||||
last_checked: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const actions = processResults(results, states, config);
|
||||
|
||||
expect(actions.alerts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips unknown monitors', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [
|
||||
{
|
||||
name: 'known',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results: CheckResult[] = [
|
||||
{
|
||||
name: 'unknown',
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'timeout',
|
||||
attempts: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const actions = processResults(results, [], config);
|
||||
|
||||
expect(actions.dbWrites).toHaveLength(0);
|
||||
expect(actions.stateUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles empty inputs', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [],
|
||||
};
|
||||
|
||||
const actions = processResults([], [], config);
|
||||
|
||||
expect(actions.dbWrites).toHaveLength(0);
|
||||
expect(actions.stateUpdates).toHaveLength(0);
|
||||
expect(actions.alerts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates default state for new monitors', () => {
|
||||
const config: Config = {
|
||||
settings: {
|
||||
defaultRetries: 3,
|
||||
defaultRetryDelayMs: 1000,
|
||||
defaultTimeoutMs: 5000,
|
||||
defaultFailureThreshold: 2,
|
||||
},
|
||||
alerts: [],
|
||||
monitors: [
|
||||
{
|
||||
name: 'new-monitor',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
headers: {},
|
||||
timeoutMs: 5000,
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
failureThreshold: 2,
|
||||
alerts: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results: CheckResult[] = [
|
||||
{
|
||||
name: 'new-monitor',
|
||||
status: 'up',
|
||||
responseTimeMs: 100,
|
||||
error: '',
|
||||
attempts: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const actions = processResults(results, [], config);
|
||||
|
||||
expect(actions.stateUpdates).toHaveLength(1);
|
||||
expect(actions.stateUpdates[0].currentStatus).toBe('up');
|
||||
expect(actions.stateUpdates[0].consecutiveFailures).toBe(0);
|
||||
});
|
||||
});
|
||||
112
src/processor/processor.ts
Normal file
112
src/processor/processor.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Config, Monitor } from '../config/types.js';
|
||||
import type {
|
||||
CheckResult,
|
||||
MonitorState,
|
||||
Actions,
|
||||
DbWrite,
|
||||
AlertCall,
|
||||
StateUpdate,
|
||||
} from './types.js';
|
||||
|
||||
export function processResults(
|
||||
results: CheckResult[],
|
||||
states: MonitorState[],
|
||||
config: Config
|
||||
): Actions {
|
||||
const monitorMap = new Map<string, Monitor>();
|
||||
for (const m of config.monitors) {
|
||||
monitorMap.set(m.name, m);
|
||||
}
|
||||
|
||||
const stateMap = new Map<string, MonitorState>();
|
||||
for (const s of states) {
|
||||
stateMap.set(s.monitor_name, s);
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const actions: Actions = {
|
||||
dbWrites: [],
|
||||
alerts: [],
|
||||
stateUpdates: [],
|
||||
};
|
||||
|
||||
for (const result of results) {
|
||||
const monitor = monitorMap.get(result.name);
|
||||
if (!monitor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = stateMap.get(result.name) ?? {
|
||||
monitor_name: result.name,
|
||||
current_status: 'up' as const,
|
||||
consecutive_failures: 0,
|
||||
last_status_change: 0,
|
||||
last_checked: 0,
|
||||
};
|
||||
|
||||
const dbWrite: DbWrite = {
|
||||
monitorName: result.name,
|
||||
checkedAt: now,
|
||||
status: result.status,
|
||||
responseTimeMs: result.responseTimeMs,
|
||||
errorMessage: result.error,
|
||||
attempts: result.attempts,
|
||||
};
|
||||
actions.dbWrites.push(dbWrite);
|
||||
|
||||
const newState: StateUpdate = {
|
||||
monitorName: result.name,
|
||||
currentStatus: state.current_status,
|
||||
consecutiveFailures: state.consecutive_failures,
|
||||
lastStatusChange: state.last_status_change,
|
||||
lastChecked: now,
|
||||
};
|
||||
|
||||
if (result.status === 'down') {
|
||||
newState.consecutiveFailures = state.consecutive_failures + 1;
|
||||
|
||||
if (
|
||||
newState.consecutiveFailures >= monitor.failureThreshold &&
|
||||
state.current_status === 'up'
|
||||
) {
|
||||
newState.currentStatus = 'down';
|
||||
newState.lastStatusChange = now;
|
||||
|
||||
for (const alertName of monitor.alerts) {
|
||||
const alert: AlertCall = {
|
||||
alertName,
|
||||
monitorName: result.name,
|
||||
alertType: 'down',
|
||||
error: result.error,
|
||||
timestamp: now,
|
||||
};
|
||||
actions.alerts.push(alert);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newState.consecutiveFailures = 0;
|
||||
newState.currentStatus = 'up';
|
||||
|
||||
if (state.current_status === 'down') {
|
||||
newState.lastStatusChange = now;
|
||||
|
||||
for (const alertName of monitor.alerts) {
|
||||
const alert: AlertCall = {
|
||||
alertName,
|
||||
monitorName: result.name,
|
||||
alertType: 'recovery',
|
||||
error: '',
|
||||
timestamp: now,
|
||||
};
|
||||
actions.alerts.push(alert);
|
||||
}
|
||||
} else {
|
||||
newState.lastStatusChange = state.last_status_change;
|
||||
}
|
||||
}
|
||||
|
||||
actions.stateUpdates.push(newState);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
46
src/processor/types.ts
Normal file
46
src/processor/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type CheckResult = {
|
||||
name: string;
|
||||
status: 'up' | 'down';
|
||||
responseTimeMs: number;
|
||||
error: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
export type MonitorState = {
|
||||
monitor_name: string;
|
||||
current_status: 'up' | 'down';
|
||||
consecutive_failures: number;
|
||||
last_status_change: number;
|
||||
last_checked: number;
|
||||
};
|
||||
|
||||
export type DbWrite = {
|
||||
monitorName: string;
|
||||
checkedAt: number;
|
||||
status: string;
|
||||
responseTimeMs: number;
|
||||
errorMessage: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
export type AlertCall = {
|
||||
alertName: string; // name from config
|
||||
monitorName: string;
|
||||
alertType: 'down' | 'recovery';
|
||||
error: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type StateUpdate = {
|
||||
monitorName: string;
|
||||
currentStatus: string;
|
||||
consecutiveFailures: number;
|
||||
lastStatusChange: number;
|
||||
lastChecked: number;
|
||||
};
|
||||
|
||||
export type Actions = {
|
||||
dbWrites: DbWrite[];
|
||||
alerts: AlertCall[]; // renamed from webhooks
|
||||
stateUpdates: StateUpdate[];
|
||||
};
|
||||
208
src/regional/checker.test.ts
Normal file
208
src/regional/checker.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type {
|
||||
HttpCheckRequest,
|
||||
TcpCheckRequest,
|
||||
DnsCheckRequest,
|
||||
CheckResult,
|
||||
Env,
|
||||
} from '../types.js';
|
||||
import { executeHttpCheck } from '../checks/http.js';
|
||||
import { executeTcpCheck } from '../checks/tcp.js';
|
||||
import { executeDnsCheck } from '../checks/dns.js';
|
||||
import { RegionalChecker } from './checker.js';
|
||||
|
||||
// Mock Cloudflare imports
|
||||
vi.mock('cloudflare:workers', () => ({
|
||||
DurableObject: class MockDurableObject {
|
||||
ctx: any;
|
||||
constructor(ctx: any, _env: any) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the check execution functions
|
||||
vi.mock('../checks/http.js', () => ({
|
||||
executeHttpCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../checks/tcp.js', () => ({
|
||||
executeTcpCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../checks/dns.js', () => ({
|
||||
executeDnsCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
const createMockDurableObjectState = () => ({
|
||||
blockConcurrencyWhile: vi.fn(),
|
||||
getAlarm: vi.fn(),
|
||||
setAlarm: vi.fn(),
|
||||
deleteAlarm: vi.fn(),
|
||||
storage: {
|
||||
get: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
list: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
},
|
||||
waitUntil: vi.fn(),
|
||||
});
|
||||
|
||||
const createMockEnv = (): Env => ({
|
||||
DB: {} as any,
|
||||
MONITORS_CONFIG: '',
|
||||
});
|
||||
|
||||
const createHttpCheckRequest = (overrides: Partial<HttpCheckRequest> = {}): HttpCheckRequest => ({
|
||||
name: 'test-check',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createTcpCheckRequest = (overrides: Partial<TcpCheckRequest> = {}): TcpCheckRequest => ({
|
||||
name: 'test-check',
|
||||
type: 'tcp',
|
||||
target: 'example.com:80',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createDnsCheckRequest = (overrides: Partial<DnsCheckRequest> = {}): DnsCheckRequest => ({
|
||||
name: 'test-check',
|
||||
type: 'dns',
|
||||
target: 'example.com',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('RegionalChecker', () => {
|
||||
let checker: RegionalChecker;
|
||||
let mockState: any;
|
||||
let mockEnv: Env;
|
||||
|
||||
beforeEach(() => {
|
||||
mockState = createMockDurableObjectState();
|
||||
mockEnv = createMockEnv();
|
||||
checker = new RegionalChecker(mockState, mockEnv);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('runCheck', () => {
|
||||
it('executes HTTP check when type is http', async () => {
|
||||
const mockResult: CheckResult = {
|
||||
name: 'test-check',
|
||||
status: 'up',
|
||||
responseTimeMs: 100,
|
||||
error: '',
|
||||
attempts: 1,
|
||||
};
|
||||
|
||||
vi.mocked(executeHttpCheck).mockResolvedValue(mockResult);
|
||||
|
||||
const check = createHttpCheckRequest();
|
||||
const result = await checker.runCheck(check);
|
||||
|
||||
expect(executeHttpCheck).toHaveBeenCalledWith(check);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('executes TCP check when type is tcp', async () => {
|
||||
const mockResult: CheckResult = {
|
||||
name: 'test-check',
|
||||
status: 'up',
|
||||
responseTimeMs: 50,
|
||||
error: '',
|
||||
attempts: 1,
|
||||
};
|
||||
|
||||
vi.mocked(executeTcpCheck).mockResolvedValue(mockResult);
|
||||
|
||||
const check = createTcpCheckRequest();
|
||||
const result = await checker.runCheck(check);
|
||||
|
||||
expect(executeTcpCheck).toHaveBeenCalledWith(check);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('executes DNS check when type is dns', async () => {
|
||||
const mockResult: CheckResult = {
|
||||
name: 'test-check',
|
||||
status: 'up',
|
||||
responseTimeMs: 30,
|
||||
error: '',
|
||||
attempts: 1,
|
||||
};
|
||||
|
||||
vi.mocked(executeDnsCheck).mockResolvedValue(mockResult);
|
||||
|
||||
const check = createDnsCheckRequest();
|
||||
const result = await checker.runCheck(check);
|
||||
|
||||
expect(executeDnsCheck).toHaveBeenCalledWith(check);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('returns down status with error when check execution throws', async () => {
|
||||
const error = new Error('Network error');
|
||||
vi.mocked(executeHttpCheck).mockRejectedValue(error);
|
||||
|
||||
const check = createHttpCheckRequest();
|
||||
const result = await checker.runCheck(check);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test-check',
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'Network error',
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles unknown error types gracefully', async () => {
|
||||
vi.mocked(executeHttpCheck).mockRejectedValue('string error');
|
||||
|
||||
const check = createHttpCheckRequest();
|
||||
const result = await checker.runCheck(check);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test-check',
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'Unknown error',
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('kill', () => {
|
||||
it('calls blockConcurrencyWhile with function that do not throws', async () => {
|
||||
let thrownError: Error | undefined;
|
||||
mockState.blockConcurrencyWhile.mockImplementation(async (fn: () => Promise<void>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
thrownError = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
await checker.kill();
|
||||
|
||||
expect(thrownError!);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
src/regional/checker.ts
Normal file
53
src/regional/checker.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { executeHttpCheck } from '../checks/http.js';
|
||||
import { executeTcpCheck } from '../checks/tcp.js';
|
||||
import { executeDnsCheck } from '../checks/dns.js';
|
||||
import type { CheckRequest, CheckResult } from '../types.js';
|
||||
|
||||
export class RegionalChecker extends DurableObject {
|
||||
async runCheck(check: CheckRequest): Promise<CheckResult> {
|
||||
console.warn(JSON.stringify({ event: 'regional_check_run', monitor: check.name }));
|
||||
|
||||
try {
|
||||
// Execute the check locally in this Durable Object's region
|
||||
switch (check.type) {
|
||||
case 'http': {
|
||||
return await executeHttpCheck(check);
|
||||
}
|
||||
|
||||
case 'tcp': {
|
||||
return await executeTcpCheck(check);
|
||||
}
|
||||
|
||||
case 'dns': {
|
||||
return await executeDnsCheck(check);
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen due to TypeScript type checking
|
||||
// But we need to satisfy TypeScript's return type
|
||||
const exhaustiveCheck: never = check;
|
||||
throw new Error(`Unknown check type: ${String(exhaustiveCheck)}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: 'regional_checker_error',
|
||||
monitor: check.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async kill(): Promise<void> {
|
||||
// No-op: Cloudflare automatically hibernates inactive Durable Objects
|
||||
// There's no need to force termination, and doing so would log errors
|
||||
}
|
||||
}
|
||||
1
src/regional/index.ts
Normal file
1
src/regional/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RegionalChecker } from './checker.js';
|
||||
124
src/types.ts
Normal file
124
src/types.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
interface CheckRequestBase {
|
||||
name: string;
|
||||
target: string;
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
region?: string; // Cloudflare region code like 'weur', 'enam', etc.
|
||||
}
|
||||
|
||||
export interface HttpCheckRequest extends CheckRequestBase {
|
||||
type: 'http';
|
||||
method?: string;
|
||||
expectedStatus?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TcpCheckRequest extends CheckRequestBase {
|
||||
type: 'tcp';
|
||||
}
|
||||
|
||||
export interface DnsCheckRequest extends CheckRequestBase {
|
||||
type: 'dns';
|
||||
recordType?: string;
|
||||
expectedValues?: string[];
|
||||
}
|
||||
|
||||
export type CheckRequest = HttpCheckRequest | TcpCheckRequest | DnsCheckRequest;
|
||||
|
||||
export type CheckResult = {
|
||||
name: string;
|
||||
status: 'up' | 'down';
|
||||
responseTimeMs: number;
|
||||
error: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
// Note: snake_case field names match the D1 database schema
|
||||
export type MonitorState = {
|
||||
monitor_name: string;
|
||||
current_status: 'up' | 'down';
|
||||
consecutive_failures: number;
|
||||
last_status_change: number;
|
||||
last_checked: number;
|
||||
};
|
||||
|
||||
export type Actions = {
|
||||
dbWrites: DbWrite[];
|
||||
alerts: AlertCall[];
|
||||
stateUpdates: StateUpdate[];
|
||||
};
|
||||
|
||||
export type DbWrite = {
|
||||
monitorName: string;
|
||||
checkedAt: number;
|
||||
status: string;
|
||||
responseTimeMs: number;
|
||||
errorMessage: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
export type AlertCall = {
|
||||
alertName: string;
|
||||
monitorName: string;
|
||||
alertType: 'down' | 'recovery';
|
||||
error: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type StateUpdate = {
|
||||
monitorName: string;
|
||||
currentStatus: string;
|
||||
consecutiveFailures: number;
|
||||
lastStatusChange: number;
|
||||
lastChecked: number;
|
||||
};
|
||||
|
||||
export type WebhookPayload = {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type Env = {
|
||||
DB: D1Database;
|
||||
MONITORS_CONFIG: string;
|
||||
STATUS_USERNAME?: string;
|
||||
STATUS_PASSWORD?: string;
|
||||
STATUS_PUBLIC?: string;
|
||||
REGIONAL_CHECKER_DO?: DurableObjectNamespace;
|
||||
ASSETS?: Fetcher;
|
||||
};
|
||||
|
||||
// Status API response types (consumed by Pages project via service binding)
|
||||
export type StatusApiResponse = {
|
||||
monitors: ApiMonitorStatus[];
|
||||
summary: {
|
||||
total: number;
|
||||
operational: number;
|
||||
down: number;
|
||||
};
|
||||
lastUpdated: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type ApiMonitorStatus = {
|
||||
name: string;
|
||||
status: 'up' | 'down' | 'unknown';
|
||||
lastChecked: number | undefined;
|
||||
uptimePercent: number;
|
||||
dailyHistory: ApiDayStatus[];
|
||||
recentChecks: ApiRecentCheck[];
|
||||
};
|
||||
|
||||
export type ApiDayStatus = {
|
||||
date: string;
|
||||
uptimePercent: number | undefined;
|
||||
};
|
||||
|
||||
export type ApiRecentCheck = {
|
||||
timestamp: number;
|
||||
status: 'up' | 'down';
|
||||
responseTimeMs: number;
|
||||
};
|
||||
9
src/utils/interpolate.ts
Normal file
9
src/utils/interpolate.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function interpolateSecrets<T extends Record<string, unknown>>(
|
||||
configYaml: string,
|
||||
env: T
|
||||
): string {
|
||||
return configYaml.replaceAll(/\$\{([^\}]+)\}/gv, (match, variableName: string) => {
|
||||
const value = env[variableName as keyof T];
|
||||
return typeof value === 'string' ? value : match;
|
||||
});
|
||||
}
|
||||
99
src/utils/region.test.ts
Normal file
99
src/utils/region.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { getWorkerLocation, isValidRegion, getValidRegions } from './region.js';
|
||||
|
||||
describe('region utilities', () => {
|
||||
describe('getWorkerLocation', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns colo location from Cloudflare trace', async () => {
|
||||
const mockTrace = 'colo=weur\nip=1.2.3.4\ntls=TLSv1.3';
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
text: async () => mockTrace,
|
||||
} as Response);
|
||||
|
||||
const location = await getWorkerLocation();
|
||||
expect(location).toBe('weur');
|
||||
expect(fetch).toHaveBeenCalledWith('https://cloudflare.com/cdn-cgi/trace');
|
||||
});
|
||||
|
||||
it('returns unknown when colo not found in trace', async () => {
|
||||
const mockTrace = 'ip=1.2.3.4\ntls=TLSv1.3';
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
text: async () => mockTrace,
|
||||
} as Response);
|
||||
|
||||
const location = await getWorkerLocation();
|
||||
expect(location).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns error when fetch fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const location = await getWorkerLocation();
|
||||
expect(location).toBe('error');
|
||||
});
|
||||
|
||||
it('handles empty response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
text: async () => '',
|
||||
} as Response);
|
||||
|
||||
const location = await getWorkerLocation();
|
||||
expect(location).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidRegion', () => {
|
||||
it('returns true for valid region codes', () => {
|
||||
expect(isValidRegion('weur')).toBe(true);
|
||||
expect(isValidRegion('enam')).toBe(true);
|
||||
expect(isValidRegion('wnam')).toBe(true);
|
||||
expect(isValidRegion('apac')).toBe(true);
|
||||
expect(isValidRegion('eeur')).toBe(true);
|
||||
expect(isValidRegion('oc')).toBe(true);
|
||||
expect(isValidRegion('safr')).toBe(true);
|
||||
expect(isValidRegion('me')).toBe(true);
|
||||
expect(isValidRegion('sam')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid region codes in uppercase', () => {
|
||||
expect(isValidRegion('WEUR')).toBe(true);
|
||||
expect(isValidRegion('ENAM')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid region codes', () => {
|
||||
expect(isValidRegion('invalid')).toBe(false);
|
||||
expect(isValidRegion('')).toBe(false);
|
||||
expect(isValidRegion('us-east')).toBe(false);
|
||||
expect(isValidRegion('eu-west')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles mixed case region codes', () => {
|
||||
expect(isValidRegion('WeUr')).toBe(true);
|
||||
expect(isValidRegion('EnAm')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidRegions', () => {
|
||||
it('returns array of valid region codes', () => {
|
||||
const regions = getValidRegions();
|
||||
expect(Array.isArray(regions)).toBe(true);
|
||||
expect(regions).toHaveLength(9);
|
||||
expect(regions).toContain('weur');
|
||||
expect(regions).toContain('enam');
|
||||
expect(regions).toContain('wnam');
|
||||
expect(regions).toContain('apac');
|
||||
expect(regions).toContain('eeur');
|
||||
expect(regions).toContain('oc');
|
||||
expect(regions).toContain('safr');
|
||||
expect(regions).toContain('me');
|
||||
expect(regions).toContain('sam');
|
||||
});
|
||||
});
|
||||
});
|
||||
68
src/utils/region.ts
Normal file
68
src/utils/region.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Get the current Cloudflare worker location (colo)
|
||||
* @returns Promise<string> The Cloudflare colo location
|
||||
*/
|
||||
export async function getWorkerLocation(): Promise<string> {
|
||||
try {
|
||||
const response = await fetch('https://cloudflare.com/cdn-cgi/trace');
|
||||
const text = await response.text();
|
||||
|
||||
// Parse the trace response to find colo
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('colo=')) {
|
||||
return line.split('=')[1];
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: 'worker_location_error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a region code is a valid Cloudflare region
|
||||
* @param region The region code to validate
|
||||
* @returns boolean True if valid
|
||||
*/
|
||||
export function isValidRegion(region: string): boolean {
|
||||
// Common Cloudflare region codes
|
||||
const validRegions = [
|
||||
'weur', // Western Europe
|
||||
'enam', // Eastern North America
|
||||
'wnam', // Western North America
|
||||
'apac', // Asia Pacific
|
||||
'eeur', // Eastern Europe
|
||||
'oc', // Oceania
|
||||
'safr', // South Africa
|
||||
'me', // Middle East
|
||||
'sam', // South America
|
||||
];
|
||||
|
||||
return validRegions.includes(region.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of valid Cloudflare region codes
|
||||
* @returns string[] Array of valid region codes
|
||||
*/
|
||||
export function getValidRegions(): string[] {
|
||||
return [
|
||||
'weur', // Western Europe
|
||||
'enam', // Eastern North America
|
||||
'wnam', // Western North America
|
||||
'apac', // Asia Pacific
|
||||
'eeur', // Eastern Europe
|
||||
'oc', // Oceania
|
||||
'safr', // South Africa
|
||||
'me', // Middle East
|
||||
'sam', // South America
|
||||
];
|
||||
}
|
||||
25
src/utils/status-emoji.test.ts
Normal file
25
src/utils/status-emoji.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { statusEmoji } from './status-emoji.js';
|
||||
|
||||
describe('statusEmoji', () => {
|
||||
it('returns green circle for up status', () => {
|
||||
expect(statusEmoji('up')).toBe('🟢');
|
||||
});
|
||||
|
||||
it('returns red circle for down status', () => {
|
||||
expect(statusEmoji('down')).toBe('🔴');
|
||||
});
|
||||
|
||||
it('returns green circle for recovery status', () => {
|
||||
expect(statusEmoji('recovery')).toBe('🟢');
|
||||
});
|
||||
|
||||
it('returns white circle for unknown status', () => {
|
||||
expect(statusEmoji('unknown')).toBe('⚪');
|
||||
});
|
||||
|
||||
it('returns white circle for unrecognized status', () => {
|
||||
expect(statusEmoji('something-else')).toBe('⚪');
|
||||
expect(statusEmoji('')).toBe('⚪');
|
||||
});
|
||||
});
|
||||
10
src/utils/status-emoji.ts
Normal file
10
src/utils/status-emoji.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const statusEmojiMap: Record<string, string> = {
|
||||
up: '🟢',
|
||||
down: '🔴',
|
||||
recovery: '🟢',
|
||||
unknown: '⚪',
|
||||
};
|
||||
|
||||
export function statusEmoji(status: string): string {
|
||||
return statusEmojiMap[status] ?? '⚪';
|
||||
}
|
||||
Reference in New Issue
Block a user