diff --git a/migrations/0003_colorful_kingpin.sql b/migrations/0003_colorful_kingpin.sql new file mode 100644 index 0000000..db602b9 --- /dev/null +++ b/migrations/0003_colorful_kingpin.sql @@ -0,0 +1,36 @@ +CREATE TABLE `incidents` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `monitor_id` integer NOT NULL, + `started_at` integer NOT NULL, + `resolved_at` integer, + `start_status_code` integer, + `start_error` text, + `duration_ms` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `incidents_monitor_id_started_at_idx` ON `incidents` (`monitor_id`,`started_at`);--> statement-breakpoint +CREATE TABLE `monitor_daily_stats` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `monitor_id` integer NOT NULL, + `day` integer NOT NULL, + `total_checks` integer NOT NULL, + `up_checks` integer NOT NULL, + `avg_latency_ms` integer, + `min_latency_ms` integer, + `max_latency_ms` integer, + FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `monitor_daily_stats_monitor_id_day_uidx` ON `monitor_daily_stats` (`monitor_id`,`day`);--> statement-breakpoint +CREATE TABLE `notification_settings` ( + `id` integer PRIMARY KEY NOT NULL, + `webhook_url` text, + `webhook_enabled` integer DEFAULT false NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE `monitors` ADD `alerts_enabled` integer DEFAULT true NOT NULL; \ No newline at end of file diff --git a/migrations/meta/0003_snapshot.json b/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..bb5d941 --- /dev/null +++ b/migrations/meta/0003_snapshot.json @@ -0,0 +1,592 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c137c06e-d876-42ae-a0a9-39115b3f0ebf", + "prevId": "92127a68-ea12-46dd-bab7-1d7d91259fd4", + "tables": { + "admin_credentials": { + "name": "admin_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checks": { + "name": "checks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "checks_monitor_id_checked_at_idx": { + "name": "checks_monitor_id_checked_at_idx", + "columns": [ + "monitor_id", + "checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checks_monitor_id_monitors_id_fk": { + "name": "checks_monitor_id_monitors_id_fk", + "tableFrom": "checks", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incidents": { + "name": "incidents", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_status_code": { + "name": "start_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_error": { + "name": "start_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "incidents_monitor_id_started_at_idx": { + "name": "incidents_monitor_id_started_at_idx", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "incidents_monitor_id_monitors_id_fk": { + "name": "incidents_monitor_id_monitors_id_fk", + "tableFrom": "incidents", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_attempts": { + "name": "login_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "login_attempts_ip_attempted_at_idx": { + "name": "login_attempts_ip_attempted_at_idx", + "columns": [ + "ip_address", + "attempted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_daily_stats": { + "name": "monitor_daily_stats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_checks": { + "name": "total_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "up_checks": { + "name": "up_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avg_latency_ms": { + "name": "avg_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_latency_ms": { + "name": "min_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_latency_ms": { + "name": "max_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_daily_stats_monitor_id_day_uidx": { + "name": "monitor_daily_stats_monitor_id_day_uidx", + "columns": [ + "monitor_id", + "day" + ], + "isUnique": true + } + }, + "foreignKeys": { + "monitor_daily_stats_monitor_id_monitors_id_fk": { + "name": "monitor_daily_stats_monitor_id_monitors_id_fk", + "tableFrom": "monitor_daily_stats", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitors": { + "name": "monitors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "expected_status": { + "name": "expected_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 200 + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "timeout_ms": { + "name": "timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10000 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "alerts_enabled": { + "name": "alerts_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_ok": { + "name": "last_ok", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status_code": { + "name": "last_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_latency_ms": { + "name": "last_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "monitors_enabled_last_checked_at_idx": { + "name": "monitors_enabled_last_checked_at_idx", + "columns": [ + "enabled", + "last_checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_settings": { + "name": "notification_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 7b4810f..34b18ec 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1787839000288, "tag": "0002_lean_mac_gargan", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1787887541718, + "tag": "0003_colorful_kingpin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/App.tsx b/src/client/App.tsx index 1c90a72..4386257 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -2,13 +2,21 @@ import { RequireAuth } from "./components/RequireAuth"; import { usePathname } from "./lib/router"; import { DashboardPage } from "./pages/DashboardPage"; import { LoginPage } from "./pages/LoginPage"; +import { MonitorDetailPage } from "./pages/MonitorDetailPage"; +import { SettingsPage } from "./pages/SettingsPage"; function App() { const pathname = usePathname(); if (pathname === "/login") return ; + const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/); + const page = monitorMatch + ? + : pathname === "/settings" + ? + : ; return ( - + {page} ); } diff --git a/src/client/api/http.ts b/src/client/api/http.ts index 0244a66..7893ebe 100644 --- a/src/client/api/http.ts +++ b/src/client/api/http.ts @@ -41,7 +41,7 @@ export async function getJson( } function sendJson( - method: "POST" | "PATCH" | "DELETE", + method: "POST" | "PUT" | "PATCH" | "DELETE", input: RequestInfo | URL, body?: unknown, init: RequestInit = {}, @@ -74,6 +74,14 @@ export function patchJson( return sendJson("PATCH", input, body, init); } +export function putJson( + input: RequestInfo | URL, + body?: unknown, + init: RequestInit = {}, +) { + return sendJson("PUT", input, body, init); +} + export function deleteJson( input: RequestInfo | URL, init: RequestInit = {}, diff --git a/src/client/api/monitors.ts b/src/client/api/monitors.ts index 8f791b9..9937244 100644 --- a/src/client/api/monitors.ts +++ b/src/client/api/monitors.ts @@ -11,6 +11,7 @@ export type Monitor = { intervalSeconds: number; timeoutMs: number; enabled: boolean; + alertsEnabled: boolean; lastOk: boolean | null; lastStatusCode: number | null; lastLatencyMs: number | null; @@ -28,6 +29,7 @@ export type MonitorInput = { intervalSeconds: number; timeoutMs: number; enabled?: boolean; + alertsEnabled?: boolean; }; export type CheckResult = { @@ -37,6 +39,36 @@ export type CheckResult = { error: string | null; }; +export type Check = CheckResult & { + id: number; + monitorId: number; + checkedAt: string; +}; + +export type Incident = { + id: number; + monitorId: number; + startedAt: string; + resolvedAt: string | null; + startStatusCode: number | null; + startError: string | null; + durationMs: number | null; + createdAt: string; + updatedAt: string; +}; + +export type StatsWindow = { + uptimePct: number | null; + totalChecks: number; + upChecks: number; + avgLatencyMs: number | null; + incidentCount: number; +}; + +export type MonitorStats = { + windows: Record<"24h" | "7d" | "30d" | "90d", StatsWindow>; +}; + export function listMonitors(signal?: AbortSignal) { return getJson<{ monitors: Monitor[] }>("/api/monitors", { signal, @@ -44,6 +76,22 @@ export function listMonitors(signal?: AbortSignal) { }); } +export function getMonitor(id: number, signal?: AbortSignal) { + return getJson<{ monitor: Monitor }>(`/api/monitors/${id}`, { signal, credentials: "same-origin" }); +} + +export function listChecks(id: number, limit = 100, signal?: AbortSignal) { + return getJson<{ checks: Check[] }>(`/api/monitors/${id}/checks?limit=${limit}`, { signal, credentials: "same-origin" }); +} + +export function getMonitorStats(id: number, signal?: AbortSignal) { + return getJson(`/api/monitors/${id}/stats`, { signal, credentials: "same-origin" }); +} + +export function listIncidents(id: number, limit = 50, signal?: AbortSignal) { + return getJson<{ incidents: Incident[] }>(`/api/monitors/${id}/incidents?limit=${limit}`, { signal, credentials: "same-origin" }); +} + export function createMonitor(input: MonitorInput) { return postJson<{ monitor: Monitor }>("/api/monitors", input); } diff --git a/src/client/api/settings.ts b/src/client/api/settings.ts new file mode 100644 index 0000000..a5f2b04 --- /dev/null +++ b/src/client/api/settings.ts @@ -0,0 +1,26 @@ +import { getJson, postJson, putJson } from "./http"; + +export type NotificationSettings = { + id: number; + webhookUrl: string | null; + webhookEnabled: boolean; + createdAt: string | null; + updatedAt: string | null; +}; + +export type NotificationSettingsInput = Pick; + +export function getNotificationSettings(signal?: AbortSignal) { + return getJson<{ settings: NotificationSettings }>("/api/settings/notifications", { + signal, + credentials: "same-origin", + }); +} + +export function updateNotificationSettings(input: NotificationSettingsInput) { + return putJson<{ settings: NotificationSettings }>("/api/settings/notifications", input); +} + +export function testNotificationWebhook() { + return postJson<{ ok: true }>("/api/settings/notifications/test"); +} diff --git a/src/client/components/charts/LatencySparkline.tsx b/src/client/components/charts/LatencySparkline.tsx new file mode 100644 index 0000000..115fc8a --- /dev/null +++ b/src/client/components/charts/LatencySparkline.tsx @@ -0,0 +1,34 @@ +import type { Check } from "../../api/monitors"; + +export function LatencySparkline({ checks }: { checks: Check[] }) { + const values = checks.toReversed().map((check) => check.latencyMs); + if (values.length < 2) return
More checks are needed to draw latency.
; + const width = 720; + const height = 180; + const padding = 12; + const minimum = Math.min(...values); + const maximum = Math.max(...values); + const range = Math.max(maximum - minimum, 1); + const points = values.map((value, index) => { + const x = padding + (index / (values.length - 1)) * (width - padding * 2); + const y = height - padding - ((value - minimum) / range) * (height - padding * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); + + return ( +
+ + + + + + + + + + + +
{maximum} ms{minimum} ms
+
+ ); +} diff --git a/src/client/components/charts/UptimeBar.tsx b/src/client/components/charts/UptimeBar.tsx new file mode 100644 index 0000000..cd5755a --- /dev/null +++ b/src/client/components/charts/UptimeBar.tsx @@ -0,0 +1,20 @@ +import type { Check } from "../../api/monitors"; + +export function UptimeBar({ checks }: { checks: Check[] }) { + if (checks.length === 0) return
No availability checks recorded yet.
; + const ordered = checks.toReversed(); + return ( +
+
check.ok).length} of ${ordered.length} recent checks succeeded`}> + {ordered.map((check) => ( + + ))} +
+
Oldest Up DownLatest
+
+ ); +} diff --git a/src/client/pages/DashboardPage.tsx b/src/client/pages/DashboardPage.tsx index e1cbbee..e5df657 100644 --- a/src/client/pages/DashboardPage.tsx +++ b/src/client/pages/DashboardPage.tsx @@ -2,6 +2,7 @@ import { type FormEvent, useState } from "react"; import { ArrowRight, Database, RefreshCw, Zap } from "lucide-react"; import type { Monitor, MonitorInput, MonitorMethod } from "../api/monitors"; import { useLogoutMutation } from "../queries/auth"; +import { navigate } from "../lib/router"; import { useCreateMonitorMutation, useDeleteMonitorMutation, @@ -70,6 +71,7 @@ export function DashboardPage() { intervalSeconds: monitor.intervalSeconds, timeoutMs: monitor.timeoutMs, enabled: monitor.enabled, + alertsEnabled: monitor.alertsEnabled, }); setFormOpen(true); } @@ -106,6 +108,7 @@ export function DashboardPage() {
Production monitors + @@ -149,7 +152,8 @@ export function DashboardPage() { -
+ +
@@ -182,9 +186,9 @@ export function DashboardPage() { const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id; return (
-
{monitor.name}{monitor.url}{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
+
{monitor.url}{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
{checking ? "Checking" : status.label}{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}
-
+
); })} diff --git a/src/client/pages/MonitorDetailPage.tsx b/src/client/pages/MonitorDetailPage.tsx new file mode 100644 index 0000000..a136a2f --- /dev/null +++ b/src/client/pages/MonitorDetailPage.tsx @@ -0,0 +1,83 @@ +import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from "lucide-react"; +import { LatencySparkline } from "../components/charts/LatencySparkline"; +import { UptimeBar } from "../components/charts/UptimeBar"; +import { navigate } from "../lib/router"; +import { useLogoutMutation } from "../queries/auth"; +import { + useMonitorChecksQuery, + useMonitorIncidentsQuery, + useMonitorQuery, + useMonitorStatsQuery, + useRunCheckMutation, +} from "../queries/monitors"; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); +} + +function formatDuration(ms: number | null, startedAt: string) { + const duration = ms ?? Math.max(0, Date.now() - new Date(startedAt).getTime()); + if (duration < 60_000) return `${Math.max(1, Math.round(duration / 1000))} sec`; + if (duration < 3_600_000) return `${Math.round(duration / 60_000)} min`; + if (duration < 86_400_000) return `${Math.round(duration / 3_600_000)} hr`; + return `${Math.round(duration / 86_400_000)} days`; +} + +function statusDetails(lastOk: boolean | null) { + if (lastOk === true) return { label: "Operational", className: "online" }; + if (lastOk === false) return { label: "Down", className: "offline" }; + return { label: "Awaiting first check", className: "checking" }; +} + +export function MonitorDetailPage({ id }: { id: number }) { + const monitorQuery = useMonitorQuery(id); + const checksQuery = useMonitorChecksQuery(id); + const statsQuery = useMonitorStatsQuery(id); + const incidentsQuery = useMonitorIncidentsQuery(id); + const checkMutation = useRunCheckMutation(); + const logoutMutation = useLogoutMutation(); + const monitor = monitorQuery.data?.monitor; + const checks = checksQuery.data?.checks ?? []; + const incidents = incidentsQuery.data?.incidents ?? []; + const status = statusDetails(monitor?.lastOk ?? null); + const openIncident = incidents.find((incident) => incident.resolvedAt === null); + + if (monitorQuery.isPending) return

Loading monitor history…

; + if (monitorQuery.isError || !monitor) return ( +
Monitor not found

The requested monitor could not be loaded.

+ ); + + return ( +
+
+ +
+
+
+ +
+

Monitor #{monitor.id}

{monitor.name}

{monitor.url}
+
{status.label}{!monitor.alertsEnabled && Alerts muted}
+
+ +
+ {(["24h", "7d", "30d", "90d"] as const).map((key) => { + const window = statsQuery.data?.windows[key]; + return

{key} uptime

{window?.uptimePct == null ? "—" : `${window.uptimePct.toFixed(3)}%`}{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? "—"} ms avg
; + })} +

Current incident

{openIncident ? formatDuration(null, openIncident.startedAt) : "None"}{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : "Everything is operational"}
+
+ +
+

Response time

Latency

Last {checks.length} checks
+

Availability

Recent uptime

{checks.filter((check) => check.ok).length}/{checks.length} successful
+
+ +
+

Event stream

Recent checks

{checks.slice(0, 20).map((check) => )}
StatusResponseLatencyChecked
{check.ok ? "Up" : "Down"}{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}{check.latencyMs} ms{formatDate(check.checkedAt)}
{checks.length === 0 &&
No checks recorded.
}
+

Downtime

Incidents

{incidents.length} recorded
{incidents.map((incident) =>
{incident.resolvedAt ? : }
{incident.resolvedAt ? "Resolved incident" : "Incident in progress"}

{incident.startError ?? (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : "Endpoint became unavailable")}

{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}
)}{incidents.length === 0 &&
No downtime incidents recorded.
}
+
+
+
+ ); +} diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx new file mode 100644 index 0000000..02a4e2c --- /dev/null +++ b/src/client/pages/SettingsPage.tsx @@ -0,0 +1,45 @@ +import { type FormEvent, useState } from "react"; +import { ArrowLeft, BellRing, Send, Zap } from "lucide-react"; +import type { NotificationSettings } from "../api/settings"; +import { navigate } from "../lib/router"; +import { useLogoutMutation } from "../queries/auth"; +import { useNotificationSettingsQuery, useTestNotificationWebhookMutation, useUpdateNotificationSettingsMutation } from "../queries/settings"; + +function SettingsForm({ settings }: { settings: NotificationSettings }) { + const [webhookUrl, setWebhookUrl] = useState(settings.webhookUrl ?? ""); + const [webhookEnabled, setWebhookEnabled] = useState(settings.webhookEnabled); + const updateMutation = useUpdateNotificationSettingsMutation(); + const testMutation = useTestNotificationWebhookMutation(); + + function submit(event: FormEvent) { + event.preventDefault(); + updateMutation.mutate({ webhookUrl: webhookUrl.trim() || null, webhookEnabled }); + } + + return
+ + +
+ {updateMutation.isSuccess &&

Notification settings saved.

} + {testMutation.isSuccess &&

Test webhook delivered successfully.

} + {(updateMutation.isError || testMutation.isError) &&

{(updateMutation.error ?? testMutation.error)?.message ?? "Request failed"}

} +
; +} + +export function SettingsPage() { + const settingsQuery = useNotificationSettingsQuery(); + const logoutMutation = useLogoutMutation(); + return
+
Settings
+

Integrations

Notifications

Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.

+

Incident webhook

Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.

{settingsQuery.isPending ?
Loading settings…
: settingsQuery.isError ?

Unable to load notification settings.

: }
+

Payload preview

{`{
+  "event": "down",
+  "monitor": { "id": 12, "name": "API", "url": "https://api.example.com" },
+  "statusCode": 500,
+  "error": "Expected HTTP 200, received 500",
+  "at": "2026-08-28T03:25:00.000Z"
+}`}
+
+
; +} diff --git a/src/client/queries/monitors.ts b/src/client/queries/monitors.ts index a1f0831..cf2cf7d 100644 --- a/src/client/queries/monitors.ts +++ b/src/client/queries/monitors.ts @@ -2,6 +2,10 @@ import { queryOptions, useMutation, useQuery } from "@tanstack/react-query"; import { createMonitor, deleteMonitor, + getMonitor, + getMonitorStats, + listChecks, + listIncidents, listMonitors, runMonitorCheck, updateMonitor, @@ -12,6 +16,10 @@ import { queryClient } from "../lib/query-client"; export const monitorKeys = { all: ["monitors"] as const, list: () => [...monitorKeys.all, "list"] as const, + detail: (id: number) => [...monitorKeys.all, "detail", id] as const, + checks: (id: number) => [...monitorKeys.all, "checks", id] as const, + stats: (id: number) => [...monitorKeys.all, "stats", id] as const, + incidents: (id: number) => [...monitorKeys.all, "incidents", id] as const, }; export const monitorsQueryOptions = () => @@ -26,6 +34,47 @@ export function useMonitorsQuery() { return useQuery(monitorsQueryOptions()); } +const liveQueryDefaults = { + refetchInterval: 60_000, + refetchIntervalInBackground: false, +} as const; + +export function useMonitorQuery(id: number) { + return useQuery({ + queryKey: monitorKeys.detail(id), + queryFn: ({ signal }) => getMonitor(id, signal), + enabled: Number.isSafeInteger(id) && id > 0, + ...liveQueryDefaults, + }); +} + +export function useMonitorChecksQuery(id: number) { + return useQuery({ + queryKey: monitorKeys.checks(id), + queryFn: ({ signal }) => listChecks(id, 100, signal), + enabled: Number.isSafeInteger(id) && id > 0, + ...liveQueryDefaults, + }); +} + +export function useMonitorStatsQuery(id: number) { + return useQuery({ + queryKey: monitorKeys.stats(id), + queryFn: ({ signal }) => getMonitorStats(id, signal), + enabled: Number.isSafeInteger(id) && id > 0, + ...liveQueryDefaults, + }); +} + +export function useMonitorIncidentsQuery(id: number) { + return useQuery({ + queryKey: monitorKeys.incidents(id), + queryFn: ({ signal }) => listIncidents(id, 50, signal), + enabled: Number.isSafeInteger(id) && id > 0, + ...liveQueryDefaults, + }); +} + function invalidateMonitors() { return queryClient.invalidateQueries({ queryKey: monitorKeys.all }); } diff --git a/src/client/queries/settings.ts b/src/client/queries/settings.ts new file mode 100644 index 0000000..f677003 --- /dev/null +++ b/src/client/queries/settings.ts @@ -0,0 +1,31 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + getNotificationSettings, + testNotificationWebhook, + updateNotificationSettings, + type NotificationSettingsInput, +} from "../api/settings"; +import { queryClient } from "../lib/query-client"; + +export const settingsKeys = { + all: ["settings"] as const, + notifications: () => [...settingsKeys.all, "notifications"] as const, +}; + +export function useNotificationSettingsQuery() { + return useQuery({ + queryKey: settingsKeys.notifications(), + queryFn: ({ signal }) => getNotificationSettings(signal), + }); +} + +export function useUpdateNotificationSettingsMutation() { + return useMutation({ + mutationFn: (input: NotificationSettingsInput) => updateNotificationSettings(input), + onSuccess: (data) => queryClient.setQueryData(settingsKeys.notifications(), data), + }); +} + +export function useTestNotificationWebhookMutation() { + return useMutation({ mutationFn: testNotificationWebhook }); +} diff --git a/src/client/styles.css b/src/client/styles.css index 4b55175..cdf061b 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -33,6 +33,7 @@ button { color: inherit; } .dashboard-header { position: sticky; z-index: 20; top: 0; border-bottom: 1px solid #ededed; background: rgb(255 255 255 / 0.94); backdrop-filter: blur(14px); } .dashboard-header-inner { display: flex; align-items: center; justify-content: space-between; width: min(1280px, calc(100% - 48px)); height: 68px; margin: 0 auto; } .brand { display: inline-flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 600; letter-spacing: -0.6px; } +.brand-button { padding: 0; border: 0; background: transparent; cursor: pointer; } .brand-mark { width: 26px; height: 26px; color: var(--primary-deep); } .nav-actions { display: flex; align-items: center; gap: 20px; } .header-context { padding-right: 20px; border-right: 1px solid #e5e5e5; font-size: 13px; color: var(--muted); } @@ -82,6 +83,7 @@ button { color: inherit; } .toggle-field { display: flex; align-items: center; gap: 9px; align-self: end; min-height: 42px; font-size: 13px; color: #444; cursor: pointer; } .toggle-field input { width: 16px; height: 16px; accent-color: var(--primary-deep); } .form-actions { display: flex; justify-content: flex-end; gap: 10px; align-self: end; grid-column: span 3; } +.form-actions.compact-actions { grid-column: span 2; } .form-error { grid-column: 1 / -1; margin: 0; padding: 10px 12px; border: 1px solid #efcaca; border-radius: 6px; font-size: 13px; color: #9f2f2f; background: #fff6f6; } .services-panel { margin-top: 24px; border: 1px solid #dedede; border-radius: 8px; overflow: hidden; background: #fff; } @@ -105,6 +107,8 @@ button { color: inherit; } .service-icon svg { width: 19px; height: 19px; } .service-name strong, .service-name small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .service-name strong { font-size: 15px; font-weight: 500; } +.monitor-name-link { display: block; max-width: 100%; padding: 0; overflow: hidden; border: 0; font-size: 15px; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; background: transparent; cursor: pointer; } +.monitor-name-link:hover { color: #16885b; text-decoration: underline; text-underline-offset: 3px; } .service-name small { margin-top: 4px; font: 400 12px/1.4 "IBM Plex Mono", monospace; color: #888; } .monitor-meta { display: block; margin-top: 5px; font-size: 12px; color: #aaa; } .monitor-result { display: grid; justify-items: start; gap: 6px; min-width: 0; } @@ -159,6 +163,92 @@ button { color: inherit; } .auth-submit:disabled { cursor: wait; opacity: 0.62; } .auth-footnote { position: relative; z-index: 1; margin: 0; font-size: 11px; color: #858585; } +.detail-main { padding-top: 34px; } +.back-link { display: inline-flex; align-items: center; gap: 7px; padding: 0; border: 0; font-size: 13px; color: var(--muted); background: transparent; cursor: pointer; } +.back-link:hover { color: var(--ink); } +.back-link svg { width: 15px; height: 15px; } +.detail-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; margin-top: 34px; padding-bottom: 36px; border-bottom: 1px solid #e7e7e7; } +.detail-title { display: flex; align-items: center; gap: 18px; min-width: 0; } +.status-orb { flex: 0 0 auto; width: 16px; height: 16px; border: 4px solid #f2d4d4; border-radius: 50%; background: #c74f4f; box-shadow: 0 0 0 8px #fff; } +.status-orb.online { border-color: #c5f1df; background: var(--primary-deep); } +.status-orb.checking { border-color: #eee5bd; background: #c6aa38; } +.detail-title h1 { margin: 0; overflow: hidden; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: -1.5px; text-overflow: ellipsis; white-space: nowrap; } +.detail-title a { display: inline-flex; align-items: center; gap: 6px; max-width: min(70vw, 680px); margin-top: 9px; overflow: hidden; font: 400 13px/1.4 "IBM Plex Mono", monospace; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } +.detail-title a:hover { color: var(--primary-deep); } +.detail-title a svg { flex: 0 0 auto; width: 13px; height: 13px; } +.detail-actions { display: flex; align-items: center; gap: 18px; } +.muted-alert { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); } +.muted-alert svg { width: 14px; height: 14px; } +.sla-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 24px; } +.sla-card { min-width: 0; padding: 20px; border: 1px solid #e3e3e3; border-radius: 8px; background: #fff; } +.sla-card p { margin: 0 0 17px; font-size: 12px; color: var(--muted); } +.sla-card strong { display: block; font: 500 23px/1 "IBM Plex Mono", monospace; letter-spacing: -1px; } +.sla-card span { display: block; margin-top: 11px; overflow: hidden; font-size: 11px; color: var(--faint); text-overflow: ellipsis; white-space: nowrap; } +.sla-card.incident-summary { background: #fafafa; } +.sla-card.incident-summary.has-incident { border-color: #e8c3c3; background: #fff8f8; } +.sla-card.incident-summary.has-incident strong { color: #a83e3e; } +.detail-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.75fr); gap: 16px; margin-top: 16px; } +.detail-grid.lower-grid { grid-template-columns: minmax(0, 1.4fr) minmax(340px, 0.8fr); align-items: start; } +.data-panel { overflow: hidden; border: 1px solid #dedede; border-radius: 8px; background: #fff; } +.data-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 82px; padding: 18px 22px; border-bottom: 1px solid #ededed; } +.data-panel-heading .overline { margin-bottom: 5px; } +.data-panel-heading h2 { margin: 0; font-size: 18px; font-weight: 500; } +.data-panel-heading > span { font: 400 11px/1.4 "IBM Plex Mono", monospace; color: var(--faint); } +.chart-panel { min-height: 330px; } +.sparkline-wrap { position: relative; height: 245px; padding: 26px 24px 24px; background: linear-gradient(180deg, #fdfefe, #fff); } +.latency-sparkline { width: 100%; height: 100%; overflow: visible; } +.chart-grid-line { fill: none; stroke: #eeeeee; stroke-width: 1; stroke-dasharray: 3 5; } +.chart-scale { position: absolute; inset: 19px 22px 20px auto; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; font: 400 9px/1 "IBM Plex Mono", monospace; color: #aaa; } +.chart-empty, .table-empty { display: grid; min-height: 150px; place-items: center; padding: 24px; font-size: 13px; color: var(--muted); text-align: center; } +.uptime-panel { min-height: 330px; } +.uptime-bar { display: flex; align-items: stretch; gap: 2px; height: 132px; padding: 28px 22px 14px; } +.uptime-bar span { flex: 1; min-width: 2px; border-radius: 2px; background: #3ecf8e; transition: transform 120ms ease, opacity 120ms ease; } +.uptime-bar span.is-down { background: #d95c5c; } +.uptime-bar span:hover { z-index: 1; opacity: .75; transform: scaleY(1.06); } +.uptime-legend { display: flex; justify-content: space-between; padding: 14px 22px 0; border-top: 1px solid #f0f0f0; font-size: 10px; color: #999; } +.uptime-legend span { display: inline-flex; align-items: center; gap: 5px; } +.uptime-legend i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); } +.uptime-legend i.legend-down { margin-left: 5px; background: #d95c5c; } +.data-table-wrap { overflow-x: auto; } +.data-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.data-table th { padding: 11px 16px; font-weight: 400; text-align: left; color: #888; background: #fafafa; } +.data-table td { padding: 14px 16px; border-top: 1px solid #ededed; color: #555; white-space: nowrap; } +.data-table code { max-width: 260px; overflow: hidden; font: 400 11px/1.4 "IBM Plex Mono", monospace; text-overflow: ellipsis; } +.incident-list { max-height: 556px; overflow: auto; } +.incident-list article { display: flex; gap: 13px; padding: 18px 20px; } +.incident-list article + article { border-top: 1px solid #ededed; } +.incident-list article > span { display: grid; flex: 0 0 auto; width: 30px; height: 30px; place-items: center; border-radius: 50%; color: #16885b; background: #eaf9f2; } +.incident-list article.open > span { color: #aa4141; background: #fff0f0; } +.incident-list article svg { width: 15px; height: 15px; } +.incident-list strong { font-size: 13px; font-weight: 500; } +.incident-list p { margin: 4px 0 6px; font-size: 12px; line-height: 1.45; color: var(--muted); } +.incident-list small { font: 400 10px/1.4 "IBM Plex Mono", monospace; color: #aaa; } +.detail-error { display: grid; min-height: 100dvh; place-content: center; justify-items: center; padding: 24px; text-align: center; } +.detail-error p { color: var(--muted); } + +.settings-main { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 34px 0 80px; } +.settings-heading { margin-top: 42px; } +.settings-heading h1 { margin: 0; font-size: 40px; font-weight: 500; letter-spacing: -1.5px; } +.settings-heading > p:last-child { margin: 12px 0 0; color: var(--muted); } +.settings-card { margin-top: 38px; overflow: hidden; border: 1px solid #dedede; border-radius: 10px; background: #fff; box-shadow: 0 8px 24px rgb(0 0 0 / .035); } +.settings-card-intro { display: flex; gap: 16px; padding: 26px; border-bottom: 1px solid #ededed; } +.settings-card-intro > span { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; border: 1px solid #bcead6; border-radius: 8px; color: #16885b; background: #f0fbf6; } +.settings-card-intro svg { width: 19px; height: 19px; } +.settings-card-intro h2 { margin: 0; font-size: 18px; font-weight: 500; } +.settings-card-intro p { margin: 6px 0 0; font-size: 13px; line-height: 1.55; color: var(--muted); } +.settings-form { display: grid; gap: 24px; padding: 26px; } +.settings-toggle { display: flex; align-items: flex-start; gap: 11px; cursor: pointer; } +.settings-toggle input { width: 17px; height: 17px; margin-top: 2px; accent-color: var(--primary-deep); } +.settings-toggle strong, .settings-toggle small { display: block; } +.settings-toggle strong { font-size: 13px; font-weight: 500; } +.settings-toggle small { margin-top: 4px; color: var(--muted); } +.settings-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 20px; border-top: 1px solid #ededed; } +.settings-actions svg { width: 14px; height: 14px; } +.settings-success { margin: -8px 0 0; padding: 10px 12px; border: 1px solid #bde9d5; border-radius: 6px; font-size: 12px; color: #16754f; background: #f2fbf7; } +.payload-preview { margin-top: 18px; padding: 24px 26px; border-radius: 9px; color: #fff; background: var(--night); } +.payload-preview .overline { color: #83dcb4; } +.payload-preview pre { margin: 16px 0 0; overflow-x: auto; font: 400 12px/1.7 "IBM Plex Mono", monospace; color: #d7d7d7; } + @keyframes spin { to { transform: rotate(360deg); } } @keyframes blink { 50% { opacity: 0.35; } } @keyframes loading-pulse { 50% { opacity: 0.4; transform: scale(0.94); } } @@ -169,9 +259,12 @@ button { color: inherit; } .monitor-form { grid-template-columns: repeat(2, 1fr); } .field-name, .field-url { grid-column: span 1; } .form-actions { grid-column: span 1; } + .form-actions.compact-actions { grid-column: span 2; } .services-title { display: none; } .service-row { grid-template-columns: minmax(280px, 1fr) minmax(200px, 0.7fr); } .row-actions { grid-column: 1 / -1; justify-content: flex-start; margin-left: 54px; } + .sla-grid { grid-template-columns: repeat(3, 1fr); } + .detail-grid, .detail-grid.lower-grid { grid-template-columns: 1fr; } } @media (max-width: 760px) { @@ -188,10 +281,17 @@ button { color: inherit; } .monitor-form { grid-template-columns: 1fr; } .field-name, .field-url, .form-actions { grid-column: auto; } .form-actions { justify-content: stretch; } + .form-actions.compact-actions { grid-column: auto; } .form-actions button { flex: 1; } .service-row { grid-template-columns: 1fr; gap: 18px; } .row-actions { grid-column: auto; margin-left: 54px; } .dashboard-page-footer { flex-direction: column; gap: 8px; } + .detail-hero { align-items: flex-start; flex-direction: column; } + .detail-actions { flex-wrap: wrap; } + .detail-title h1 { font-size: 34px; } + .sla-grid { grid-template-columns: repeat(2, 1fr); } + .sla-card.incident-summary { grid-column: 1 / -1; } + .settings-main { width: min(100% - 32px, 760px); } } @media (max-width: 520px) { @@ -205,6 +305,15 @@ button { color: inherit; } .service-row { padding: 18px 16px; } .row-actions { margin-left: 0; } .row-actions button { flex: 1; } + .nav-actions { gap: 12px; } + .detail-title { align-items: flex-start; } + .status-orb { margin-top: 11px; } + .sla-grid { grid-template-columns: 1fr; } + .sla-card.incident-summary { grid-column: auto; } + .data-panel-heading { padding: 16px; } + .settings-heading h1 { font-size: 34px; } + .settings-card-intro, .settings-form { padding: 20px; } + .settings-actions { align-items: stretch; flex-direction: column-reverse; } } @media (prefers-reduced-motion: reduce) { diff --git a/src/worker/checks/persist-result.ts b/src/worker/checks/persist-result.ts new file mode 100644 index 0000000..f9bd975 --- /dev/null +++ b/src/worker/checks/persist-result.ts @@ -0,0 +1,71 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { checks, incidents, monitors } from "../db/schema"; +import type { CheckResult, Monitor } from "./run-check"; + +export type IncidentTransition = "opened" | "resolved" | null; + +type BatchStatement = Parameters[0][number]; + +export function buildResultStatements( + db: Database, + monitor: Monitor, + result: CheckResult, + checkedAt: Date, +) { + const statements: BatchStatement[] = [ + db.insert(checks).values({ + monitorId: monitor.id, + ok: result.ok, + statusCode: result.statusCode, + latencyMs: result.latencyMs, + error: result.error, + checkedAt, + }), + db + .update(monitors) + .set({ + lastOk: result.ok, + lastStatusCode: result.statusCode, + lastLatencyMs: result.latencyMs, + lastError: result.error, + lastCheckedAt: checkedAt, + updatedAt: checkedAt, + }) + .where(eq(monitors.id, monitor.id)), + ]; + + let transition: IncidentTransition = null; + if (monitor.lastOk !== false && !result.ok) { + statements.push( + db.insert(incidents).values({ + monitorId: monitor.id, + startedAt: checkedAt, + startStatusCode: result.statusCode, + startError: result.error, + createdAt: checkedAt, + updatedAt: checkedAt, + }), + ); + transition = "opened"; + } else if (monitor.lastOk === false && result.ok) { + statements.push( + db + .update(incidents) + .set({ + resolvedAt: checkedAt, + durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`, + updatedAt: checkedAt, + }) + .where( + and( + eq(incidents.monitorId, monitor.id), + isNull(incidents.resolvedAt), + ), + ), + ); + transition = "resolved"; + } + + return { statements, transition }; +} diff --git a/src/worker/checks/run-due-checks.ts b/src/worker/checks/run-due-checks.ts index caa6148..48ad358 100644 --- a/src/worker/checks/run-due-checks.ts +++ b/src/worker/checks/run-due-checks.ts @@ -1,6 +1,8 @@ import { and, eq, sql } from "drizzle-orm"; import { getDb } from "../db/client"; -import { checks, monitors } from "../db/schema"; +import { monitors } from "../db/schema"; +import { sendIncidentAlert } from "../notifications/webhook"; +import { buildResultStatements } from "./persist-result"; import { runCheck } from "./run-check"; const MAX_MONITORS_PER_RUN = 40; @@ -12,7 +14,10 @@ export type DueCheckSummary = { down: number; }; -export async function runDueChecks(env: Env): Promise { +export async function runDueChecks( + env: Env, + ctx?: Pick, +): Promise { const db = getDb(env); const now = Date.now(); const due = await db @@ -47,27 +52,30 @@ export async function runDueChecks(env: Env): Promise { completed.push(...results); } - const statements = completed.flatMap(({ monitor, result, checkedAt }) => [ - db.insert(checks).values({ - monitorId: monitor.id, - ok: result.ok, - statusCode: result.statusCode, - latencyMs: result.latencyMs, - error: result.error, - checkedAt, - }), - db.update(monitors).set({ - lastOk: result.ok, - lastStatusCode: result.statusCode, - lastLatencyMs: result.latencyMs, - lastError: result.error, - lastCheckedAt: checkedAt, - updatedAt: checkedAt, - }).where(eq(monitors.id, monitor.id)), - ]); + const persisted = completed.map(({ monitor, result, checkedAt }) => ({ + monitor, + result, + checkedAt, + ...buildResultStatements(db, monitor, result, checkedAt), + })); + const statements = persisted.flatMap((item) => item.statements); await db.batch(statements as [typeof statements[number], ...typeof statements]); + const notifications = persisted.flatMap((item) => item.transition === null ? [] : [ + sendIncidentAlert(env, { + monitor: item.monitor, + kind: item.transition, + result: item.result, + at: item.checkedAt, + }), + ]); + if (notifications.length > 0) { + const notificationWork = Promise.all(notifications).then(() => undefined); + if (ctx) ctx.waitUntil(notificationWork); + else await notificationWork; + } + const up = completed.reduce((count, item) => count + Number(item.result.ok), 0); return { checked: completed.length, up, down: completed.length - up }; } diff --git a/src/worker/db/schema.ts b/src/worker/db/schema.ts index 4efa52c..539b4c3 100644 --- a/src/worker/db/schema.ts +++ b/src/worker/db/schema.ts @@ -1,4 +1,4 @@ -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; export const adminCredentials = sqliteTable("admin_credentials", { id: integer("id").primaryKey(), @@ -7,6 +7,14 @@ export const adminCredentials = sqliteTable("admin_credentials", { updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }); +export const notificationSettings = sqliteTable("notification_settings", { + id: integer("id").primaryKey(), + webhookUrl: text("webhook_url"), + webhookEnabled: integer("webhook_enabled", { mode: "boolean" }).notNull().default(false), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), +}); + export const sessions = sqliteTable( "sessions", { @@ -39,6 +47,7 @@ export const monitors = sqliteTable( intervalSeconds: integer("interval_seconds").notNull().default(300), timeoutMs: integer("timeout_ms").notNull().default(10_000), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + alertsEnabled: integer("alerts_enabled", { mode: "boolean" }).notNull().default(true), lastOk: integer("last_ok", { mode: "boolean" }), lastStatusCode: integer("last_status_code"), lastLatencyMs: integer("last_latency_ms"), @@ -75,3 +84,42 @@ export const checks = sqliteTable( ), ], ); + +export const incidents = sqliteTable( + "incidents", + { + id: integer("id").primaryKey({ autoIncrement: true }), + monitorId: integer("monitor_id") + .notNull() + .references(() => monitors.id, { onDelete: "cascade" }), + startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), + resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }), + startStatusCode: integer("start_status_code"), + startError: text("start_error"), + durationMs: integer("duration_ms"), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [ + index("incidents_monitor_id_started_at_idx").on(table.monitorId, table.startedAt), + ], +); + +export const monitorDailyStats = sqliteTable( + "monitor_daily_stats", + { + id: integer("id").primaryKey({ autoIncrement: true }), + monitorId: integer("monitor_id") + .notNull() + .references(() => monitors.id, { onDelete: "cascade" }), + day: integer("day", { mode: "timestamp_ms" }).notNull(), + totalChecks: integer("total_checks").notNull(), + upChecks: integer("up_checks").notNull(), + avgLatencyMs: integer("avg_latency_ms"), + minLatencyMs: integer("min_latency_ms"), + maxLatencyMs: integer("max_latency_ms"), + }, + (table) => [ + uniqueIndex("monitor_daily_stats_monitor_id_day_uidx").on(table.monitorId, table.day), + ], +); diff --git a/src/worker/index.ts b/src/worker/index.ts index c9b68df..b2789c4 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -3,7 +3,9 @@ import { csrf } from "hono/csrf"; import { runDueChecks } from "./checks/run-due-checks"; import authRoutes from "./routes/auth"; import monitorRoutes from "./routes/monitors"; +import settingsRoutes from "./routes/settings"; import { cleanupExpiredAuthRecords } from "./scheduled/cleanup"; +import { runDailyRollup } from "./scheduled/rollup"; const app = new Hono<{ Bindings: Env }>(); @@ -23,12 +25,24 @@ app.get("/api/health", async (context) => { app.route("/", authRoutes); app.route("/api/monitors", monitorRoutes); +app.route("/api/settings", settingsRoutes); export default { fetch: app.fetch, - async scheduled(controller, env) { + async scheduled(controller, env, ctx) { + if (controller.cron === "5 0 * * *") { + const result = await runDailyRollup(env, new Date(controller.scheduledTime)); + console.log(JSON.stringify({ + message: "daily rollup completed", + cron: controller.cron, + scheduledTime: controller.scheduledTime, + ...result, + })); + return; + } + await cleanupExpiredAuthRecords(env); - const result = await runDueChecks(env); + const result = await runDueChecks(env, ctx); console.log( JSON.stringify({ message: "scheduled run completed", diff --git a/src/worker/notifications/webhook.ts b/src/worker/notifications/webhook.ts new file mode 100644 index 0000000..5662361 --- /dev/null +++ b/src/worker/notifications/webhook.ts @@ -0,0 +1,92 @@ +import { eq } from "drizzle-orm"; +import type { CheckResult, Monitor } from "../checks/run-check"; +import { getDb } from "../db/client"; +import { notificationSettings } from "../db/schema"; + +export type IncidentAlert = { + monitor: Monitor; + kind: "opened" | "resolved"; + result: CheckResult; + at: Date; +}; + +type WebhookPayload = { + event: "down" | "recovered" | "test"; + monitor: { id: number; name: string; url: string }; + statusCode: number | null; + error: string | null; + at: string; +}; + +async function postWebhook(url: string, payload: WebhookPayload): Promise { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Upwatch/1.0 (+incident webhook)", + }, + body: JSON.stringify(payload), + }); + await response.body?.cancel(); + return response.ok; +} + +export async function sendTestWebhook(url: string): Promise { + try { + return await postWebhook(url, { + event: "test", + monitor: { id: 0, name: "Upwatch test", url: "https://example.com/health" }, + statusCode: 200, + error: null, + at: new Date().toISOString(), + }); + } catch (error) { + console.error(JSON.stringify({ + message: "test webhook failed", + error: error instanceof Error ? error.message : String(error), + })); + return false; + } +} + +export async function sendIncidentAlert( + env: Env, + alert: IncidentAlert, +): Promise { + if (!alert.monitor.alertsEnabled) return false; + + try { + const [settings] = await getDb(env) + .select() + .from(notificationSettings) + .where(eq(notificationSettings.id, 1)) + .limit(1); + if (!settings?.webhookEnabled || !settings.webhookUrl) return false; + + const ok = await postWebhook(settings.webhookUrl, { + event: alert.kind === "opened" ? "down" : "recovered", + monitor: { + id: alert.monitor.id, + name: alert.monitor.name, + url: alert.monitor.url, + }, + statusCode: alert.result.statusCode, + error: alert.result.error, + at: alert.at.toISOString(), + }); + if (!ok) { + console.warn(JSON.stringify({ + message: "incident webhook returned an error", + monitorId: alert.monitor.id, + })); + } + return ok; + } catch (error) { + console.error(JSON.stringify({ + message: "incident webhook failed", + error: error instanceof Error ? error.message : String(error), + monitorId: alert.monitor.id, + })); + return false; + } +} diff --git a/src/worker/routes/monitors.ts b/src/worker/routes/monitors.ts index 023a56e..27b78ac 100644 --- a/src/worker/routes/monitors.ts +++ b/src/worker/routes/monitors.ts @@ -1,9 +1,11 @@ -import { eq } from "drizzle-orm"; +import { and, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm"; import { Hono } from "hono"; +import { buildResultStatements } from "../checks/persist-result"; import { runCheck } from "../checks/run-check"; import { getDb } from "../db/client"; -import { checks, monitors } from "../db/schema"; +import { checks, incidents, monitorDailyStats, monitors } from "../db/schema"; import { requireAuth, type AuthVariables } from "../lib/require-auth"; +import { sendIncidentAlert } from "../notifications/webhook"; type MonitorMethod = "GET" | "HEAD" | "POST"; @@ -15,6 +17,7 @@ type ParsedMonitorInput = { intervalSeconds?: number; timeoutMs?: number; enabled?: boolean; + alertsEnabled?: boolean; }; type ParseResult = @@ -91,6 +94,12 @@ export function parseMonitorInput(body: unknown, partial = false): ParseResult { } value.enabled = body.enabled; } + if ("alertsEnabled" in body) { + if (typeof body.alertsEnabled !== "boolean") { + return { ok: false, message: "alertsEnabled must be a boolean" }; + } + value.alertsEnabled = body.alertsEnabled; + } return { ok: true, value }; } @@ -100,6 +109,33 @@ function parseId(rawId: string) { return Number.isSafeInteger(id) && id > 0 ? id : null; } +function parseLimit(raw: string | undefined, fallback: number, maximum: number) { + if (!raw) return fallback; + const value = Number(raw); + return Number.isSafeInteger(value) && value > 0 ? Math.min(value, maximum) : fallback; +} + +type StatsWindow = { + uptimePct: number | null; + totalChecks: number; + upChecks: number; + avgLatencyMs: number | null; + incidentCount: number; +}; + +function asStatsWindow( + row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null }, + incidentCount: number, +): StatsWindow { + return { + uptimePct: row.totalChecks > 0 ? Math.round((row.upChecks / row.totalChecks) * 100_000) / 1_000 : null, + totalChecks: row.totalChecks, + upChecks: row.upChecks, + avgLatencyMs: row.avgLatencyMs, + incidentCount, + }; +} + const monitorRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); monitorRoutes.use("*", requireAuth); @@ -109,6 +145,89 @@ monitorRoutes.get("/", async (context) => { return context.json({ monitors: rows }); }); +monitorRoutes.get("/:id", async (context) => { + const id = parseId(context.req.param("id")); + if (id === null) return context.json({ message: "Monitor not found" }, 404); + const [monitor] = await getDb(context.env).select().from(monitors).where(eq(monitors.id, id)).limit(1); + if (!monitor) return context.json({ message: "Monitor not found" }, 404); + return context.json({ monitor }); +}); + +monitorRoutes.get("/:id/checks", async (context) => { + const id = parseId(context.req.param("id")); + if (id === null) return context.json({ message: "Monitor not found" }, 404); + const limit = parseLimit(context.req.query("limit"), 100, 500); + const rows = await getDb(context.env) + .select() + .from(checks) + .where(eq(checks.monitorId, id)) + .orderBy(desc(checks.checkedAt)) + .limit(limit); + return context.json({ checks: rows }); +}); + +monitorRoutes.get("/:id/incidents", async (context) => { + const id = parseId(context.req.param("id")); + if (id === null) return context.json({ message: "Monitor not found" }, 404); + const limit = parseLimit(context.req.query("limit"), 50, 200); + const rows = await getDb(context.env) + .select() + .from(incidents) + .where(eq(incidents.monitorId, id)) + .orderBy(desc(incidents.startedAt)) + .limit(limit); + return context.json({ incidents: rows }); +}); + +monitorRoutes.get("/:id/stats", async (context) => { + const id = parseId(context.req.param("id")); + if (id === null) return context.json({ message: "Monitor not found" }, 404); + const db = getDb(context.env); + const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1); + if (!monitor) return context.json({ message: "Monitor not found" }, 404); + + const now = Date.now(); + const currentDayMs = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate()); + const windows = [ + { key: "24h", start: now - 24 * 60 * 60 * 1000, raw: true }, + { key: "7d", start: now - 7 * 24 * 60 * 60 * 1000, raw: true }, + { key: "30d", start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false }, + { key: "90d", start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false }, + ] as const; + + const results = await Promise.all(windows.map(async (window) => { + const [aggregate] = window.raw + ? await db.select({ + totalChecks: sql`count(*)`, + upChecks: sql`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`, + avgLatencyMs: sql`round(avg(${checks.latencyMs}))`, + }).from(checks).where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start)))) + : await db.select({ + totalChecks: sql`coalesce(sum(total_checks), 0)`, + upChecks: sql`coalesce(sum(up_checks), 0)`, + avgLatencyMs: sql`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`, + }).from(sql`( + select total_checks, up_checks, avg_latency_ms + from monitor_daily_stats + where monitor_id = ${id} and day >= ${window.start} and day < ${currentDayMs} + union all + select count(*), coalesce(sum(case when ok = 1 then 1 else 0 end), 0), round(avg(latency_ms)) + from checks + where monitor_id = ${id} and checked_at >= ${currentDayMs} + )`); + const [incidentAggregate] = await db.select({ count: sql`count(*)` }) + .from(incidents) + .where(and( + eq(incidents.monitorId, id), + gte(incidents.startedAt, new Date(window.start)), + or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))), + )); + return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const; + })); + + return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]["key"], StatsWindow> }); +}); + monitorRoutes.post("/", async (context) => { let body: unknown; try { @@ -131,6 +250,7 @@ monitorRoutes.post("/", async (context) => { intervalSeconds: parsed.value.intervalSeconds!, timeoutMs: parsed.value.timeoutMs!, enabled: parsed.value.enabled ?? true, + alertsEnabled: parsed.value.alertsEnabled ?? true, createdAt: now, updatedAt: now, }) @@ -191,26 +311,14 @@ monitorRoutes.post("/:id/check", async (context) => { const result = await runCheck(monitor); const checkedAt = new Date(); - const [, updated] = await db.batch([ - db.insert(checks).values({ - monitorId: monitor.id, - ok: result.ok, - statusCode: result.statusCode, - latencyMs: result.latencyMs, - error: result.error, - checkedAt, - }), - db.update(monitors).set({ - lastOk: result.ok, - lastStatusCode: result.statusCode, - lastLatencyMs: result.latencyMs, - lastError: result.error, - lastCheckedAt: checkedAt, - updatedAt: checkedAt, - }).where(eq(monitors.id, monitor.id)).returning(), - ]); + const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt); + await db.batch(statements as [typeof statements[number], ...typeof statements]); + if (transition) { + await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt }); + } + const [updated] = await db.select().from(monitors).where(eq(monitors.id, monitor.id)).limit(1); - return context.json({ result, monitor: updated[0] }); + return context.json({ result, monitor: updated }); }); export default monitorRoutes; diff --git a/src/worker/routes/settings.ts b/src/worker/routes/settings.ts new file mode 100644 index 0000000..c0186ef --- /dev/null +++ b/src/worker/routes/settings.ts @@ -0,0 +1,86 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { getDb } from "../db/client"; +import { notificationSettings } from "../db/schema"; +import { requireAuth, type AuthVariables } from "../lib/require-auth"; +import { sendTestWebhook } from "../notifications/webhook"; + +type NotificationInput = { + webhookUrl: string | null; + webhookEnabled: boolean; +}; + +function parseNotificationInput(value: unknown): NotificationInput | string { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return "Invalid request body"; + } + const body = value as Record; + if (typeof body.webhookEnabled !== "boolean") { + return "webhookEnabled must be a boolean"; + } + const rawUrl = typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : body.webhookUrl; + if (rawUrl !== null && typeof rawUrl !== "string") return "webhookUrl must be a URL or null"; + let webhookUrl = rawUrl || null; + if (webhookUrl) { + try { + const url = new URL(webhookUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("protocol"); + webhookUrl = url.toString(); + } catch { + return "Enter a valid http or https webhook URL"; + } + } + if (body.webhookEnabled && !webhookUrl) return "A webhook URL is required when alerts are enabled"; + return { webhookUrl, webhookEnabled: body.webhookEnabled }; +} + +const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); +settingsRoutes.use("*", requireAuth); + +settingsRoutes.get("/notifications", async (context) => { + const [settings] = await getDb(context.env) + .select() + .from(notificationSettings) + .where(eq(notificationSettings.id, 1)) + .limit(1); + return context.json({ + settings: settings ?? { id: 1, webhookUrl: null, webhookEnabled: false, createdAt: null, updatedAt: null }, + }); +}); + +settingsRoutes.put("/notifications", async (context) => { + let body: unknown; + try { + body = await context.req.json(); + } catch { + return context.json({ message: "Invalid request body" }, 400); + } + const input = parseNotificationInput(body); + if (typeof input === "string") return context.json({ message: input }, 400); + + const db = getDb(context.env); + const now = new Date(); + const [settings] = await db + .insert(notificationSettings) + .values({ id: 1, ...input, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ + target: notificationSettings.id, + set: { ...input, updatedAt: now }, + }) + .returning(); + return context.json({ settings }); +}); + +settingsRoutes.post("/notifications/test", async (context) => { + const [settings] = await getDb(context.env) + .select() + .from(notificationSettings) + .where(eq(notificationSettings.id, 1)) + .limit(1); + if (!settings?.webhookUrl) return context.json({ message: "Save a webhook URL first" }, 400); + const delivered = await sendTestWebhook(settings.webhookUrl); + if (!delivered) return context.json({ message: "Webhook delivery failed" }, 502); + return context.json({ ok: true }); +}); + +export default settingsRoutes; diff --git a/src/worker/scheduled/cleanup.ts b/src/worker/scheduled/cleanup.ts index af335c7..c247e85 100644 --- a/src/worker/scheduled/cleanup.ts +++ b/src/worker/scheduled/cleanup.ts @@ -1,9 +1,10 @@ import { lt } from "drizzle-orm"; import { getDb } from "../db/client"; -import { checks, loginAttempts, sessions } from "../db/schema"; +import { checks, loginAttempts, monitorDailyStats, sessions } from "../db/schema"; const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000; const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const DAILY_STATS_RETENTION_MS = 400 * 24 * 60 * 60 * 1000; export async function cleanupExpiredAuthRecords(env: Env) { const db = getDb(env); @@ -27,5 +28,13 @@ export async function cleanupExpiredAuthRecords(env: Env) { new Date(now.getTime() - CHECK_RETENTION_MS), ), ), + db + .delete(monitorDailyStats) + .where( + lt( + monitorDailyStats.day, + new Date(now.getTime() - DAILY_STATS_RETENTION_MS), + ), + ), ]); } diff --git a/src/worker/scheduled/rollup.ts b/src/worker/scheduled/rollup.ts new file mode 100644 index 0000000..786c08e --- /dev/null +++ b/src/worker/scheduled/rollup.ts @@ -0,0 +1,58 @@ +import { and, gte, lt, sql } from "drizzle-orm"; +import { getDb } from "../db/client"; +import { checks, monitorDailyStats } from "../db/schema"; + +export type DailyRollupSummary = { + day: string; + monitors: number; +}; + +export async function runDailyRollup( + env: Env, + now = new Date(), +): Promise { + const db = getDb(env); + const currentUtcDay = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const dayStart = new Date(currentUtcDay - 24 * 60 * 60 * 1000); + const dayEnd = new Date(currentUtcDay); + + const rows = await db + .select({ + monitorId: checks.monitorId, + totalChecks: sql`count(*)`, + upChecks: sql`sum(case when ${checks.ok} = 1 then 1 else 0 end)`, + avgLatencyMs: sql`round(avg(${checks.latencyMs}))`, + minLatencyMs: sql`min(${checks.latencyMs})`, + maxLatencyMs: sql`max(${checks.latencyMs})`, + }) + .from(checks) + .where(and(gte(checks.checkedAt, dayStart), lt(checks.checkedAt, dayEnd))) + .groupBy(checks.monitorId); + + if (rows.length > 0) { + const statements = rows.map((row) => db + .insert(monitorDailyStats) + .values({ + monitorId: row.monitorId, + day: dayStart, + totalChecks: row.totalChecks, + upChecks: row.upChecks, + avgLatencyMs: row.avgLatencyMs, + minLatencyMs: row.minLatencyMs, + maxLatencyMs: row.maxLatencyMs, + }) + .onConflictDoUpdate({ + target: [monitorDailyStats.monitorId, monitorDailyStats.day], + set: { + totalChecks: row.totalChecks, + upChecks: row.upChecks, + avgLatencyMs: row.avgLatencyMs, + minLatencyMs: row.minLatencyMs, + maxLatencyMs: row.maxLatencyMs, + }, + })); + await db.batch(statements as [typeof statements[number], ...typeof statements]); + } + + return { day: dayStart.toISOString().slice(0, 10), monitors: rows.length }; +} diff --git a/test/checks.spec.ts b/test/checks.spec.ts index 050dd58..9c6a9ca 100644 --- a/test/checks.spec.ts +++ b/test/checks.spec.ts @@ -5,6 +5,9 @@ import { runDueChecks } from "../src/worker/checks/run-due-checks"; async function clearMonitoringTables() { await env.DB.batch([ env.DB.prepare("DELETE FROM checks"), + env.DB.prepare("DELETE FROM incidents"), + env.DB.prepare("DELETE FROM monitor_daily_stats"), + env.DB.prepare("DELETE FROM notification_settings"), env.DB.prepare("DELETE FROM monitors"), ]); } @@ -26,8 +29,8 @@ async function insertMonitor(overrides: Record = {}) { }; const result = await env.DB.prepare(` INSERT INTO monitors - (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, last_checked_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, last_ok, last_checked_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) `) .bind( values.name, @@ -37,6 +40,7 @@ async function insertMonitor(overrides: Record = {}) { values.interval_seconds, values.timeout_ms, values.enabled, + overrides.last_ok ?? null, values.last_checked_at, values.created_at, values.updated_at, @@ -88,6 +92,41 @@ describe("scheduled monitor checks", () => { last_status_code: 500, last_error: "Expected HTTP 200, received 500", }); + const incident = await env.DB.prepare("SELECT monitor_id, resolved_at, start_status_code, start_error FROM incidents WHERE monitor_id = ?") + .bind(id) + .first<{ monitor_id: number; resolved_at: number | null; start_status_code: number; start_error: string }>(); + expect(incident).toEqual({ + monitor_id: id, + resolved_at: null, + start_status_code: 500, + start_error: "Expected HTTP 200, received 500", + }); + }); + + it("does not open duplicate incidents while a monitor stays down", async () => { + const id = await insertMonitor({ last_ok: 0 }); + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 503 }))); + await runDueChecks(env); + const count = await env.DB.prepare("SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?") + .bind(id) + .first<{ count: number }>(); + expect(count?.count).toBe(0); + }); + + it("resolves the open incident on recovery", async () => { + const id = await insertMonitor({ last_ok: 0 }); + const startedAt = Date.now() - 60_000; + await env.DB.prepare("INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)") + .bind(id, startedAt, startedAt, startedAt) + .run(); + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 }))); + + await runDueChecks(env); + const incident = await env.DB.prepare("SELECT resolved_at, duration_ms FROM incidents WHERE monitor_id = ?") + .bind(id) + .first<{ resolved_at: number | null; duration_ms: number | null }>(); + expect(incident?.resolved_at).toEqual(expect.any(Number)); + expect(incident?.duration_ms).toBeGreaterThanOrEqual(60_000); }); it("skips disabled and not-yet-due monitors", async () => { diff --git a/test/monitors.spec.ts b/test/monitors.spec.ts index 6c13e2b..81c1862 100644 --- a/test/monitors.spec.ts +++ b/test/monitors.spec.ts @@ -15,6 +15,9 @@ const VALID_MONITOR = { async function seedAdmin() { await env.DB.batch([ env.DB.prepare("DELETE FROM checks"), + env.DB.prepare("DELETE FROM incidents"), + env.DB.prepare("DELETE FROM monitor_daily_stats"), + env.DB.prepare("DELETE FROM notification_settings"), env.DB.prepare("DELETE FROM monitors"), env.DB.prepare("DELETE FROM login_attempts"), env.DB.prepare("DELETE FROM sessions"), @@ -80,10 +83,10 @@ describe("monitor API", () => { it("creates a valid monitor and returns it in the list", async () => { const cookie = await authenticatedCookie(); const response = await createMonitor(cookie); - const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean } }>(); + const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean; alertsEnabled: boolean } }>(); expect(response.status).toBe(200); - expect(created.monitor).toMatchObject({ name: "Example", enabled: true }); + expect(created.monitor).toMatchObject({ name: "Example", enabled: true, alertsEnabled: true }); const listResponse = await apiFetch("/api/monitors", "GET", cookie); const list = await listResponse.json<{ monitors: Array<{ id: number; url: string }> }>(); @@ -118,6 +121,52 @@ describe("monitor API", () => { }); }); + it("returns detail, checks, incidents, and raw stats", async () => { + const cookie = await authenticatedCookie(); + const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>(); + const id = created.monitor.id; + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 100, ?)").bind(id, now - 2000), + env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)").bind(id, now - 1000), + env.DB.prepare("INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)").bind(id, now - 1000, now - 1000, now - 1000), + ]); + + const [detail, checksResponse, incidentsResponse, statsResponse] = await Promise.all([ + apiFetch(`/api/monitors/${id}`, "GET", cookie), + apiFetch(`/api/monitors/${id}/checks`, "GET", cookie), + apiFetch(`/api/monitors/${id}/incidents`, "GET", cookie), + apiFetch(`/api/monitors/${id}/stats`, "GET", cookie), + ]); + expect((await detail.json<{ monitor: { id: number } }>()).monitor.id).toBe(id); + expect((await checksResponse.json<{ checks: unknown[] }>()).checks).toHaveLength(2); + expect((await incidentsResponse.json<{ incidents: unknown[] }>()).incidents).toHaveLength(1); + const stats = await statsResponse.json<{ windows: { "24h": { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number; incidentCount: number } } }>(); + expect(stats.windows["24h"]).toEqual({ uptimePct: 50, totalChecks: 2, upChecks: 1, avgLatencyMs: 200, incidentCount: 1 }); + }); + + it("combines daily rollups with the current partial day for long-range stats", async () => { + const cookie = await authenticatedCookie(); + const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>(); + const id = created.monitor.id; + const now = new Date(); + const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + await env.DB.batch([ + env.DB.prepare("INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)").bind(id, today - 24 * 60 * 60 * 1000), + env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, today + 1000), + env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, today + 2000), + ]); + + const response = await apiFetch(`/api/monitors/${id}/stats`, "GET", cookie); + const stats = await response.json<{ windows: { "30d": { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number } } }>(); + expect(stats.windows["30d"]).toMatchObject({ + uptimePct: 80, + totalChecks: 10, + upChecks: 8, + avgLatencyMs: 120, + }); + }); + it("deletes the monitor and its check records explicitly", async () => { const cookie = await authenticatedCookie(); const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>(); diff --git a/test/rollup.spec.ts b/test/rollup.spec.ts new file mode 100644 index 0000000..ce478e9 --- /dev/null +++ b/test/rollup.spec.ts @@ -0,0 +1,45 @@ +import { applyD1Migrations, env, type D1Migration } from "cloudflare:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { runDailyRollup } from "../src/worker/scheduled/rollup"; + +describe("daily monitor rollups", () => { + beforeAll(async () => { + const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + }); + beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM monitor_daily_stats"), + env.DB.prepare("DELETE FROM checks"), + env.DB.prepare("DELETE FROM incidents"), + env.DB.prepare("DELETE FROM monitors"), + ]); + }); + + it("aggregates the previous UTC day and safely upserts on rerun", async () => { + const now = new Date("2026-08-28T00:05:00.000Z"); + const createdAt = now.getTime(); + const insert = await env.DB.prepare("INSERT INTO monitors (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, created_at, updated_at) VALUES ('API', 'https://example.com', 'GET', 200, 300, 10000, 1, 1, ?, ?)") + .bind(createdAt, createdAt) + .run(); + const id = Number(insert.meta.last_row_id); + await env.DB.batch([ + env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 100, ?)").bind(id, Date.parse("2026-08-27T02:00:00Z")), + env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, Date.parse("2026-08-27T12:00:00Z")), + env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)").bind(id, Date.parse("2026-08-27T22:00:00Z")), + ]); + + expect(await runDailyRollup(env, now)).toEqual({ day: "2026-08-27", monitors: 1 }); + await runDailyRollup(env, now); + const rows = await env.DB.prepare("SELECT day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms FROM monitor_daily_stats WHERE monitor_id = ?").bind(id).all(); + expect(rows.results).toHaveLength(1); + expect(rows.results[0]).toEqual({ + day: Date.parse("2026-08-27T00:00:00Z"), + total_checks: 3, + up_checks: 2, + avg_latency_ms: 200, + min_latency_ms: 100, + max_latency_ms: 300, + }); + }); +}); diff --git a/test/webhook.spec.ts b/test/webhook.spec.ts new file mode 100644 index 0000000..9c0c3c0 --- /dev/null +++ b/test/webhook.spec.ts @@ -0,0 +1,44 @@ +import { applyD1Migrations, env, type D1Migration } from "cloudflare:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { sendIncidentAlert } from "../src/worker/notifications/webhook"; +import type { Monitor } from "../src/worker/checks/run-check"; + +describe("incident webhooks", () => { + beforeAll(async () => { + const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + }); + beforeEach(async () => { + await env.DB.prepare("DELETE FROM notification_settings").run(); + const now = Date.now(); + await env.DB.prepare("INSERT INTO notification_settings (id, webhook_url, webhook_enabled, created_at, updated_at) VALUES (1, 'https://hooks.example.test/events', 1, ?, ?)").bind(now, now).run(); + }); + afterEach(() => vi.unstubAllGlobals()); + + const monitor = { + id: 7, name: "API", url: "https://api.example.com", method: "GET", expectedStatus: 200, + intervalSeconds: 300, timeoutMs: 10000, enabled: true, alertsEnabled: true, lastOk: true, + lastStatusCode: 200, lastLatencyMs: 30, lastError: null, lastCheckedAt: null, + createdAt: new Date(), updatedAt: new Date(), + } satisfies Monitor; + + it("sends the compact down payload", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + const at = new Date("2026-08-28T03:25:00Z"); + expect(await sendIncidentAlert(env, { monitor, kind: "opened", result: { ok: false, statusCode: 500, latencyMs: 42, error: "Down" }, at })).toBe(true); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(String(init?.body))).toEqual({ + event: "down", + monitor: { id: 7, name: "API", url: "https://api.example.com" }, + statusCode: 500, + error: "Down", + at: "2026-08-28T03:25:00.000Z", + }); + }); + + it("swallows webhook network failures", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network unavailable"); })); + await expect(sendIncidentAlert(env, { monitor, kind: "resolved", result: { ok: true, statusCode: 200, latencyMs: 20, error: null }, at: new Date() })).resolves.toBe(false); + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 690d636..51cbbef 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,7 +10,7 @@ "run_worker_first": ["/api/*"] }, "triggers": { - "crons": ["*/5 * * * *"] + "crons": ["*/5 * * * *", "5 0 * * *"] }, "d1_databases": [ {