feat: maintenance mode for monitors (#7)

This commit is contained in:
2026-04-17 18:42:23 +02:00
committed by GitHub
parent 0ab0221276
commit 3b074977ed
14 changed files with 279 additions and 22 deletions

View File

@@ -108,6 +108,37 @@ describe('getStatusApiData', () => {
expect(result.title).toBe('Test Status Page');
});
it('surfaces maintenance status and excludes from up/down counts', async () => {
const now = Math.floor(Date.now() / 1000);
const db = mockD1Database({
states: [
{ monitor_name: 'up-monitor', current_status: 'up', last_checked: now },
{ monitor_name: 'maint', current_status: 'maintenance', last_checked: now },
{ monitor_name: 'down-monitor', current_status: 'down', last_checked: now },
],
hourly: [],
recent: [
{
monitor_name: 'maint',
checked_at: now - 10,
status: 'maintenance',
response_time_ms: 0,
},
],
});
const result = await getStatusApiData(db, testConfig);
const maint = result.monitors.find(m => m.name === 'maint');
expect(maint).toBeDefined();
expect(maint!.status).toBe('maintenance');
expect(maint!.recentChecks[0]).toEqual({
timestamp: now - 10,
status: 'maintenance',
responseTimeMs: 0,
});
// Only up and down counted in summary
expect(result.summary).toEqual({ total: 3, operational: 1, down: 1 });
});
it('does not count unknown status monitors as down', async () => {
const now = Math.floor(Date.now() / 1000);
const db = mockD1Database({

View File

@@ -70,15 +70,17 @@ export async function getStatusApiData(
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 status: 'up' | 'down' | 'unknown' | 'maintenance' =
state.current_status === 'maintenance'
? 'maintenance'
: 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),
status: c.status === 'maintenance' ? 'maintenance' : c.status === 'up' ? 'up' : 'down',
responseTimeMs: c.response_time_ms ?? 0,
}));

View File

@@ -68,6 +68,37 @@ function applyDefaults(raw: RawYamlConfig): Config {
failureThreshold: m.failure_threshold ?? settings.defaultFailureThreshold,
alerts: m.alerts ?? [],
region: m.region && isValidRegion(m.region) ? m.region : undefined,
maintenance: Array.isArray(m.maintenance)
? m.maintenance.filter((w: any) => {
if (
!w ||
typeof w !== 'object' ||
typeof w.start !== 'string' ||
typeof w.end !== 'string'
)
return false;
const startMs = Date.parse(w.start);
const endMs = Date.parse(w.end);
if (
isNaN(startMs) ||
isNaN(endMs) ||
!w.start.endsWith('Z') ||
!w.end.endsWith('Z') ||
endMs <= startMs
) {
console.warn(
JSON.stringify({
event: 'invalid_maintenance_window',
start: w.start,
end: w.end,
monitor: m.name,
})
);
return false;
}
return true;
})
: undefined,
};
const type = (m.type as 'http' | 'tcp' | 'dns') ?? 'http';

View File

@@ -19,6 +19,11 @@ export type WebhookAlert = AlertBase & {
export type Alert = WebhookAlert; // | EmailAlert | ...
interface MonitorBase {
/**
* List of maintenance windows. If now is >= start and < end,
* monitor is treated as "maintenance". Times must be ISO8601 UTC (with 'Z').
*/
maintenance?: { start: string; end: string }[];
name: string;
target: string;
timeoutMs: number;
@@ -87,5 +92,6 @@ export type RawYamlConfig = {
failure_threshold?: number;
alerts?: string[];
region?: string; // Cloudflare region code for regional checks
maintenance?: { start: string; end: string }[];
}>;
};

View File

@@ -0,0 +1,2 @@
// Temporary import for next edit
import { isInMaintenance } from '../utils/maintenance.js';

View File

@@ -7,6 +7,7 @@ import type {
AlertCall,
StateUpdate,
} from './types.js';
import { isInMaintenance } from '../utils/maintenance.js';
export function processResults(
results: CheckResult[],
@@ -35,6 +36,8 @@ export function processResults(
if (!monitor) {
continue;
}
// Maintenance check
const inMaintenance = isInMaintenance(monitor.maintenance, new Date());
const state = stateMap.get(result.name) ?? {
monitor_name: result.name,
@@ -56,15 +59,23 @@ export function processResults(
const newState: StateUpdate = {
monitorName: result.name,
currentStatus: state.current_status,
currentStatus: inMaintenance ? 'maintenance' : state.current_status,
consecutiveFailures: state.consecutive_failures,
lastStatusChange: state.last_status_change,
lastChecked: now,
};
// Only update downtime/failure/recovery/alerts logic if not in maintenance
if (inMaintenance) {
// Alert suppression: no alerts for down or recovery
// But downtime is recorded (dbWrite above)
// State persists in 'maintenance', reset nothing
actions.stateUpdates.push(newState);
continue;
}
if (result.status === 'down') {
newState.consecutiveFailures = state.consecutive_failures + 1;
if (
newState.consecutiveFailures >= monitor.failureThreshold &&
state.current_status === 'up'
@@ -86,10 +97,8 @@ export function processResults(
} 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,
@@ -104,7 +113,6 @@ export function processResults(
newState.lastStatusChange = state.last_status_change;
}
}
actions.stateUpdates.push(newState);
}

View File

@@ -8,7 +8,7 @@ export type CheckResult = {
export type MonitorState = {
monitor_name: string;
current_status: 'up' | 'down';
current_status: 'up' | 'down' | 'maintenance';
consecutive_failures: number;
last_status_change: number;
last_checked: number;
@@ -33,7 +33,7 @@ export type AlertCall = {
export type StateUpdate = {
monitorName: string;
currentStatus: string;
currentStatus: 'up' | 'down' | 'maintenance';
consecutiveFailures: number;
lastStatusChange: number;
lastChecked: number;

View File

@@ -108,7 +108,14 @@ export type StatusApiResponse = {
export type ApiMonitorStatus = {
name: string;
status: 'up' | 'down' | 'unknown';
/**
* Current status of the monitor.
* 'up' - healthy
* 'down' - failing
* 'unknown' - initial/undefined
* 'maintenance' - within a configured maintenance window (alerts suppressed, shown as maintenance in UI)
*/
status: 'up' | 'down' | 'unknown' | 'maintenance';
lastChecked: number | undefined;
uptimePercent: number;
dailyHistory: ApiDayStatus[];
@@ -122,6 +129,11 @@ export type ApiDayStatus = {
export type ApiRecentCheck = {
timestamp: number;
status: 'up' | 'down';
/**
* Status for a single check event.
* Usually 'up' or 'down',
* but 'maintenance' if check occurred during a maintenance window.
*/
status: 'up' | 'down' | 'maintenance';
responseTimeMs: number;
};

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { isInMaintenance, MaintenanceWindow } from './maintenance';
function utc(date: string) {
// Shortcut for Date creation
return new Date(date);
}
describe('isInMaintenance', () => {
it('returns false when maintenance undefined or empty', () => {
expect(isInMaintenance(undefined, utc('2026-05-01T10:00:00Z'))).toBe(false);
expect(isInMaintenance([], utc('2026-05-01T10:00:00Z'))).toBe(false);
});
it('includes and excludes at precise boundaries', () => {
const mw: MaintenanceWindow[] = [
{ start: '2026-05-01T10:00:00Z', end: '2026-05-01T12:00:00Z' },
];
expect(isInMaintenance(mw, utc('2026-05-01T09:59:59Z'))).toBe(false);
expect(isInMaintenance(mw, utc('2026-05-01T10:00:00Z'))).toBe(true); // start boundary, inclusive
expect(isInMaintenance(mw, utc('2026-05-01T11:59:59Z'))).toBe(true);
expect(isInMaintenance(mw, utc('2026-05-01T12:00:00Z'))).toBe(false); // end boundary, exclusive
});
it('handles overlapping windows', () => {
const mw: MaintenanceWindow[] = [
{ start: '2026-05-01T10:00:00Z', end: '2026-05-01T11:00:00Z' },
{ start: '2026-05-01T10:30:00Z', end: '2026-05-01T11:30:00Z' },
];
expect(isInMaintenance(mw, utc('2026-05-01T10:45:00Z'))).toBe(true);
expect(isInMaintenance(mw, utc('2026-05-01T11:15:00Z'))).toBe(true);
expect(isInMaintenance(mw, utc('2026-05-01T11:30:00Z'))).toBe(false);
});
it('ignores malformed windows (should not reach here)', () => {
// A test for the future if parser passes bad data. Should stay false.
const mw = [{ start: 'bad', end: 'also-bad' }] as any;
expect(isInMaintenance(mw, utc('2026-05-01T10:00:00Z'))).toBe(false);
});
it('prefers the first valid match if multiple windows overlap', () => {
const mw: MaintenanceWindow[] = [
{ start: '2026-05-01T08:00:00Z', end: '2026-05-01T11:00:00Z' },
{ start: '2026-05-01T10:00:00Z', end: '2026-05-01T12:00:00Z' },
];
expect(isInMaintenance(mw, utc('2026-05-01T09:00:00Z'))).toBe(true);
expect(isInMaintenance(mw, utc('2026-05-01T11:00:00Z'))).toBe(true);
expect(isInMaintenance(mw, utc('2026-05-01T12:01:00Z'))).toBe(false);
});
});

27
src/utils/maintenance.ts Normal file
View File

@@ -0,0 +1,27 @@
// Utility to determine if a monitor is in maintenance based on maintenance windows and current time
// All times must be strict ISO8601 with 'Z' (UTC). End is exclusive. Windows must be validated beforehand.
export interface MaintenanceWindow {
start: string; // ISO8601 UTC
end: string; // ISO8601 UTC
}
/**
* Returns true if now is within any valid maintenance window.
* start is inclusive, end is exclusive (UTC).
* Malformed windows should have been filtered out by config parser.
* Overlapping windows are fine.
*/
export function isInMaintenance(maintenance: MaintenanceWindow[] | undefined, now: Date): boolean {
if (!maintenance || maintenance.length === 0) return false;
const nowMs = now.getTime();
for (const w of maintenance) {
const startMs = Date.parse(w.start);
const endMs = Date.parse(w.end);
if (isNaN(startMs) || isNaN(endMs) || endMs <= startMs) continue; // skip malformed
if (nowMs >= startMs && nowMs < endMs) {
return true;
}
}
return false;
}