feat: add tracking feature

This commit is contained in:
2026-08-28 10:48:35 +07:00
parent 32c1be3d75
commit 9140397ec9
29 changed files with 1826 additions and 55 deletions
+36
View File
@@ -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;
+592
View File
@@ -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": {}
}
}
+7
View File
@@ -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
}
]
}
+9 -1
View File
@@ -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 <LoginPage />;
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
const page = monitorMatch
? <MonitorDetailPage id={Number(monitorMatch[1])} />
: pathname === "/settings"
? <SettingsPage />
: <DashboardPage />;
return (
<RequireAuth>
<DashboardPage />
{page}
</RequireAuth>
);
}
+9 -1
View File
@@ -41,7 +41,7 @@ export async function getJson<T>(
}
function sendJson<T>(
method: "POST" | "PATCH" | "DELETE",
method: "POST" | "PUT" | "PATCH" | "DELETE",
input: RequestInfo | URL,
body?: unknown,
init: RequestInit = {},
@@ -74,6 +74,14 @@ export function patchJson<T>(
return sendJson<T>("PATCH", input, body, init);
}
export function putJson<T>(
input: RequestInfo | URL,
body?: unknown,
init: RequestInit = {},
) {
return sendJson<T>("PUT", input, body, init);
}
export function deleteJson<T>(
input: RequestInfo | URL,
init: RequestInit = {},
+48
View File
@@ -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<MonitorStats>(`/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);
}
+26
View File
@@ -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<NotificationSettings, "webhookUrl" | "webhookEnabled">;
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");
}
@@ -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 <div className="chart-empty">More checks are needed to draw latency.</div>;
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 (
<div className="sparkline-wrap">
<svg className="latency-sparkline" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}>
<defs>
<linearGradient id="latency-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3ecf8e" stopOpacity="0.22" />
<stop offset="1" stopColor="#3ecf8e" stopOpacity="0" />
</linearGradient>
</defs>
<path className="chart-grid-line" d={`M 0 ${height * 0.25} H ${width} M 0 ${height * 0.5} H ${width} M 0 ${height * 0.75} H ${width}`} />
<polygon points={`${padding},${height - padding} ${points} ${width - padding},${height - padding}`} fill="url(#latency-fill)" />
<polyline points={points} fill="none" stroke="#24b47e" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
</svg>
<div className="chart-scale"><span>{maximum} ms</span><span>{minimum} ms</span></div>
</div>
);
}
@@ -0,0 +1,20 @@
import type { Check } from "../../api/monitors";
export function UptimeBar({ checks }: { checks: Check[] }) {
if (checks.length === 0) return <div className="chart-empty">No availability checks recorded yet.</div>;
const ordered = checks.toReversed();
return (
<div>
<div className="uptime-bar" role="img" aria-label={`${ordered.filter((check) => check.ok).length} of ${ordered.length} recent checks succeeded`}>
{ordered.map((check) => (
<span
className={check.ok ? "is-up" : "is-down"}
key={check.id}
title={`${new Date(check.checkedAt).toLocaleString()}${check.ok ? "Up" : "Down"}`}
/>
))}
</div>
<div className="uptime-legend"><span>Oldest</span><span><i className="legend-up" /> Up <i className="legend-down" /> Down</span><span>Latest</span></div>
</div>
);
}
+7 -3
View File
@@ -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() {
</a>
<div className="nav-actions">
<span className="header-context">Production monitors</span>
<button className="nav-auth" type="button" onClick={() => navigate("/settings")}>Settings</button>
<button className="nav-auth" type="button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
{logoutMutation.isPending ? "Signing out…" : "Sign out"}
</button>
@@ -149,7 +152,8 @@ export function DashboardPage() {
<label className="field"><span>Interval</span><select value={form.intervalSeconds} onChange={(event) => setForm({ ...form, intervalSeconds: Number(event.target.value) })}><option value="300">5 minutes</option><option value="900">15 minutes</option><option value="1800">30 minutes</option><option value="3600">1 hour</option><option value="86400">24 hours</option></select></label>
<label className="field"><span>Timeout (ms)</span><input type="number" min="1000" max="30000" step="1000" value={form.timeoutMs} onChange={(event) => setForm({ ...form, timeoutMs: event.target.valueAsNumber })} required /></label>
<label className="toggle-field"><input type="checkbox" checked={form.enabled ?? true} onChange={(event) => setForm({ ...form, enabled: event.target.checked })} /><span>Enable scheduled checks</span></label>
<div className="form-actions">
<label className="toggle-field"><input type="checkbox" checked={form.alertsEnabled ?? true} onChange={(event) => setForm({ ...form, alertsEnabled: event.target.checked })} /><span>Enable incident alerts</span></label>
<div className="form-actions compact-actions">
<button className="secondary-button" type="button" onClick={closeForm}>Cancel</button>
<button className="primary-button" type="submit" disabled={formMutation.isPending}>{formMutation.isPending ? "Saving…" : editing ? "Save changes" : "Add monitor"}</button>
</div>
@@ -182,9 +186,9 @@ export function DashboardPage() {
const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id;
return (
<article className={`service-row ${monitor.enabled ? "" : "is-disabled"}`} key={monitor.id}>
<div className="service-name"><span className="service-icon"><Database /></span><div><strong>{monitor.name}</strong><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div>
<div className="service-name"><span className="service-icon"><Database /></span><div><button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>{monitor.name}</button><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div>
<div className="monitor-result"><span className={`row-status ${checking ? "checking" : status.className}`}><i />{checking ? "Checking" : status.label}</span><code>{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}</code><small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small></div>
<div className="row-actions"><button type="button" onClick={() => checkMutation.mutate(monitor.id)} disabled={checking}>Check now</button><button type="button" onClick={() => openEditForm(monitor)}>Edit</button><button type="button" onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })} disabled={toggling}>{monitor.enabled ? "Disable" : "Enable"}</button><button className="danger-action" type="button" onClick={() => handleDelete(monitor)} disabled={deleting}>{deleting ? "Deleting…" : "Delete"}</button></div>
<div className="row-actions"><button type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>History</button><button type="button" onClick={() => checkMutation.mutate(monitor.id)} disabled={checking}>Check now</button><button type="button" onClick={() => openEditForm(monitor)}>Edit</button><button type="button" onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })} disabled={toggling}>{monitor.enabled ? "Disable" : "Enable"}</button><button className="danger-action" type="button" onClick={() => handleDelete(monitor)} disabled={deleting}>{deleting ? "Deleting…" : "Delete"}</button></div>
</article>
);
})}
+83
View File
@@ -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 <div className="full-page-loading"><RefreshCw className="loading-mark" /><p>Loading monitor history</p></div>;
if (monitorQuery.isError || !monitor) return (
<div className="detail-error"><strong>Monitor not found</strong><p>The requested monitor could not be loaded.</p><button className="secondary-button" onClick={() => navigate("/")}>Return to dashboard</button></div>
);
return (
<div className="dashboard-shell">
<header className="dashboard-header"><div className="dashboard-header-inner">
<button className="brand brand-button" type="button" onClick={() => navigate("/")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button>
<div className="nav-actions"><button className="nav-auth" onClick={() => navigate("/settings")}>Settings</button><button className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>Sign out</button></div>
</div></header>
<main className="dashboard-main detail-main">
<button className="back-link" type="button" onClick={() => navigate("/")}><ArrowLeft /> All monitors</button>
<section className="detail-hero">
<div className="detail-title"><span className={`status-orb ${status.className}`} /><div><p className="overline">Monitor #{monitor.id}</p><h1>{monitor.name}</h1><a href={monitor.url} target="_blank" rel="noreferrer">{monitor.url}<ExternalLink /></a></div></div>
<div className="detail-actions"><span className={`row-status ${status.className}`}><i />{status.label}</span>{!monitor.alertsEnabled && <span className="muted-alert"><BellOff /> Alerts muted</span>}<button className="primary-button" type="button" onClick={() => checkMutation.mutate(id)} disabled={checkMutation.isPending}>{checkMutation.isPending ? "Checking…" : "Check now"}</button></div>
</section>
<section className="sla-grid" aria-label="Uptime windows">
{(["24h", "7d", "30d", "90d"] as const).map((key) => {
const window = statsQuery.data?.windows[key];
return <article className="sla-card" key={key}><p>{key} uptime</p><strong>{window?.uptimePct == null ? "—" : `${window.uptimePct.toFixed(3)}%`}</strong><span>{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? "—"} ms avg</span></article>;
})}
<article className={`sla-card incident-summary ${openIncident ? "has-incident" : ""}`}><p>Current incident</p><strong>{openIncident ? formatDuration(null, openIncident.startedAt) : "None"}</strong><span>{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : "Everything is operational"}</span></article>
</section>
<div className="detail-grid">
<section className="data-panel chart-panel"><div className="data-panel-heading"><div><p className="overline">Response time</p><h2>Latency</h2></div><span>Last {checks.length} checks</span></div><LatencySparkline checks={checks} /></section>
<section className="data-panel uptime-panel"><div className="data-panel-heading"><div><p className="overline">Availability</p><h2>Recent uptime</h2></div><span>{checks.filter((check) => check.ok).length}/{checks.length} successful</span></div><UptimeBar checks={checks} /></section>
</div>
<div className="detail-grid lower-grid">
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Event stream</p><h2>Recent checks</h2></div></div><div className="data-table-wrap"><table className="data-table"><thead><tr><th>Status</th><th>Response</th><th>Latency</th><th>Checked</th></tr></thead><tbody>{checks.slice(0, 20).map((check) => <tr key={check.id}><td><span className={`row-status ${check.ok ? "online" : "offline"}`}><i />{check.ok ? "Up" : "Down"}</span></td><td><code>{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}</code></td><td>{check.latencyMs} ms</td><td>{formatDate(check.checkedAt)}</td></tr>)}</tbody></table>{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}</div></section>
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Downtime</p><h2>Incidents</h2></div><span>{incidents.length} recorded</span></div><div className="incident-list">{incidents.map((incident) => <article className={incident.resolvedAt ? "resolved" : "open"} key={incident.id}><span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span><div><strong>{incident.resolvedAt ? "Resolved incident" : "Incident in progress"}</strong><p>{incident.startError ?? (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : "Endpoint became unavailable")}</p><small>{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}</small></div></article>)}{incidents.length === 0 && <div className="table-empty">No downtime incidents recorded.</div>}</div></section>
</div>
</main>
</div>
);
}
+45
View File
@@ -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 <form className="settings-form" onSubmit={submit}>
<label className="field"><span>Webhook URL</span><input type="url" value={webhookUrl} onChange={(event) => setWebhookUrl(event.target.value)} placeholder="https://hooks.example.com/services/…" /></label>
<label className="settings-toggle"><input type="checkbox" checked={webhookEnabled} onChange={(event) => setWebhookEnabled(event.target.checked)} /><span><strong>Enable incident alerts</strong><small>Send a webhook when a monitor goes down and when it recovers.</small></span></label>
<div className="settings-actions"><button className="secondary-button" type="button" onClick={() => testMutation.mutate()} disabled={!settings.webhookUrl || testMutation.isPending}><Send /> {testMutation.isPending ? "Sending…" : "Send test"}</button><button className="primary-button" type="submit" disabled={updateMutation.isPending}>{updateMutation.isPending ? "Saving…" : "Save settings"}</button></div>
{updateMutation.isSuccess && <p className="settings-success">Notification settings saved.</p>}
{testMutation.isSuccess && <p className="settings-success">Test webhook delivered successfully.</p>}
{(updateMutation.isError || testMutation.isError) && <p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? "Request failed"}</p>}
</form>;
}
export function SettingsPage() {
const settingsQuery = useNotificationSettingsQuery();
const logoutMutation = useLogoutMutation();
return <div className="dashboard-shell">
<header className="dashboard-header"><div className="dashboard-header-inner"><button className="brand brand-button" onClick={() => navigate("/")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button><div className="nav-actions"><span className="header-context">Settings</span><button className="nav-auth" onClick={() => logoutMutation.mutate()}>Sign out</button></div></div></header>
<main className="settings-main"><button className="back-link" type="button" onClick={() => navigate("/")}><ArrowLeft /> Dashboard</button><section className="settings-heading"><p className="overline">Integrations</p><h1>Notifications</h1><p>Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.</p></section>
<section className="settings-card"><div className="settings-card-intro"><span><BellRing /></span><div><h2>Incident webhook</h2><p>Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.</p></div></div>{settingsQuery.isPending ? <div className="table-empty">Loading settings</div> : settingsQuery.isError ? <p className="form-error">Unable to load notification settings.</p> : <SettingsForm key={settingsQuery.data.settings.updatedAt ?? "new"} settings={settingsQuery.data.settings} />}</section>
<section className="payload-preview"><p className="overline">Payload preview</p><pre>{`{
"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"
}`}</pre></section>
</main>
</div>;
}
+49
View File
@@ -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 });
}
+31
View File
@@ -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 });
}
+109
View File
@@ -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) {
+71
View File
@@ -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<Database["batch"]>[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 };
}
+27 -19
View File
@@ -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<DueCheckSummary> {
export async function runDueChecks(
env: Env,
ctx?: Pick<ExecutionContext, "waitUntil">,
): Promise<DueCheckSummary> {
const db = getDb(env);
const now = Date.now();
const due = await db
@@ -47,27 +52,30 @@ export async function runDueChecks(env: Env): Promise<DueCheckSummary> {
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,
const persisted = completed.map(({ monitor, result, checkedAt }) => ({
monitor,
result,
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)),
]);
...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 };
}
+49 -1
View File
@@ -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),
],
);
+16 -2
View File
@@ -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",
+92
View File
@@ -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<boolean> {
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<boolean> {
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<boolean> {
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;
}
}
+129 -21
View File
@@ -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<number>`count(*)`,
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
}).from(checks).where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
: await db.select({
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
avgLatencyMs: sql<number | null>`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<number>`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;
+86
View File
@@ -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<string, unknown>;
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;
+10 -1
View File
@@ -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),
),
),
]);
}
+58
View File
@@ -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<DailyRollupSummary> {
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<number>`count(*)`,
upChecks: sql<number>`sum(case when ${checks.ok} = 1 then 1 else 0 end)`,
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
minLatencyMs: sql<number | null>`min(${checks.latencyMs})`,
maxLatencyMs: sql<number | null>`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 };
}
+41 -2
View File
@@ -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<string, unknown> = {}) {
};
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<string, unknown> = {}) {
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 () => {
+51 -2
View File
@@ -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 } }>();
+45
View File
@@ -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,
});
});
});
+44
View File
@@ -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);
});
});
+1 -1
View File
@@ -10,7 +10,7 @@
"run_worker_first": ["/api/*"]
},
"triggers": {
"crons": ["*/5 * * * *"]
"crons": ["*/5 * * * *", "5 0 * * *"]
},
"d1_databases": [
{