mirror of
https://github.com/dcarrillo/atalaya.git
synced 2026-04-18 02:24:05 +00:00
feat: maintenance mode for monitors (#7)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }[];
|
||||
}>;
|
||||
};
|
||||
|
||||
2
src/processor/maintenance-import.ts
Normal file
2
src/processor/maintenance-import.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Temporary import for next edit
|
||||
import { isInMaintenance } from '../utils/maintenance.js';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
src/types.ts
16
src/types.ts
@@ -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;
|
||||
};
|
||||
|
||||
50
src/utils/maintenance.test.ts
Normal file
50
src/utils/maintenance.test.ts
Normal 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
27
src/utils/maintenance.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user