+
+
@@ -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
;
+ if (monitorQuery.isError || !monitor) return (
+
Monitor not foundThe requested monitor could not be loaded.
+ );
+
+ return (
+
+
+
+
+
+
+ {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"}
+
+
+
+
Last {checks.length} checks
+
Availability
Recent uptime
{checks.filter((check) => check.ok).length}/{checks.length} successful
+
+
+
+
Event stream
Recent checks
| Status | Response | Latency | Checked |
{checks.slice(0, 20).map((check) => | {check.ok ? "Up" : "Down"} | {check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"} | {check.latencyMs} ms | {formatDate(check.checkedAt)} |
)}
{checks.length === 0 &&
No checks recorded.
}
+
{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
;
+}
+
+export function SettingsPage() {
+ const settingsQuery = useNotificationSettingsQuery();
+ const logoutMutation = useLogoutMutation();
+ return
+
+
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": [
{