Skip to content

Commit f8d4bcf

Browse files
ralyodioclaude
andcommitted
feat(uptime): V1 uptime monitoring + down/recovery email alerts
The headline PRD feature (§3–§7): HTTP/keyword/SSL/TCP monitors with an up/down state machine, incident tracking, and email alerts. - migration: monitors, monitor_incidents, monitor_checks (owner-scoped RLS). - lib/uptime.ts: per-type checks (fetch / tls / net), multi-failure confirmation state machine, incident open/close, Resend down+recovery alerts. - worker: uptimeSweep on a 15s interval; claims due monitors then checks. - actions + UI: Uptime tab with monitor list (live state/latency), add-monitor form (per type), pause/delete, and incident history. - nav: Uptime tab after Security. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1d607fb commit f8d4bcf

8 files changed

Lines changed: 879 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { createMonitor, type CreateMonitorInput } from "@/app/actions/monitors";
6+
7+
type MonitorType = "http" | "keyword" | "ssl" | "tcp";
8+
9+
const TYPE_HINT: Record<MonitorType, string> = {
10+
http: "URL to request — up when it returns 2xx/3xx.",
11+
keyword: "URL + a keyword that must appear (or not) in the response body.",
12+
ssl: "Host — alerts when the TLS cert nears expiry.",
13+
tcp: "host:port — up when the port accepts a connection.",
14+
};
15+
16+
export function AddMonitorForm({ projectId }: { projectId: string }) {
17+
const router = useRouter();
18+
const [open, setOpen] = useState(false);
19+
const [type, setType] = useState<MonitorType>("http");
20+
const [pending, start] = useTransition();
21+
const [error, setError] = useState<string | null>(null);
22+
23+
function submit(formData: FormData) {
24+
setError(null);
25+
const input: CreateMonitorInput = {
26+
projectId,
27+
type,
28+
name: String(formData.get("name") ?? ""),
29+
target: String(formData.get("target") ?? ""),
30+
intervalS: Number(formData.get("intervalS") ?? 60),
31+
alertEmail: String(formData.get("alertEmail") ?? ""),
32+
keyword: String(formData.get("keyword") ?? ""),
33+
match: (String(formData.get("match") ?? "present") as "present" | "absent"),
34+
};
35+
start(async () => {
36+
const res = await createMonitor(input);
37+
if (!res.ok) {
38+
setError(res.error ?? "Failed.");
39+
return;
40+
}
41+
setOpen(false);
42+
router.refresh();
43+
});
44+
}
45+
46+
if (!open) {
47+
return (
48+
<button
49+
onClick={() => setOpen(true)}
50+
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--color-accent-fg)]"
51+
>
52+
Add monitor
53+
</button>
54+
);
55+
}
56+
57+
return (
58+
<form
59+
action={submit}
60+
className="flex flex-col gap-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4"
61+
>
62+
<div className="flex flex-wrap gap-3">
63+
<label className="flex flex-col gap-1 text-xs">
64+
<span className="text-[var(--color-muted)]">Type</span>
65+
<select
66+
value={type}
67+
onChange={(e) => setType(e.target.value as MonitorType)}
68+
className="rounded border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-sm"
69+
>
70+
<option value="http">HTTP(S)</option>
71+
<option value="keyword">Keyword</option>
72+
<option value="ssl">SSL expiry</option>
73+
<option value="tcp">TCP port</option>
74+
</select>
75+
</label>
76+
<label className="flex flex-1 flex-col gap-1 text-xs">
77+
<span className="text-[var(--color-muted)]">Name</span>
78+
<input
79+
name="name"
80+
required
81+
placeholder="My site"
82+
className="rounded border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-sm"
83+
/>
84+
</label>
85+
</div>
86+
87+
<label className="flex flex-col gap-1 text-xs">
88+
<span className="text-[var(--color-muted)]">Target</span>
89+
<input
90+
name="target"
91+
required
92+
placeholder={type === "tcp" ? "db.example.com:5432" : type === "ssl" ? "example.com" : "https://example.com"}
93+
className="rounded border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-sm font-mono"
94+
/>
95+
<span className="text-[var(--color-muted)]">{TYPE_HINT[type]}</span>
96+
</label>
97+
98+
{type === "keyword" && (
99+
<div className="flex flex-wrap gap-3">
100+
<label className="flex flex-1 flex-col gap-1 text-xs">
101+
<span className="text-[var(--color-muted)]">Keyword</span>
102+
<input
103+
name="keyword"
104+
placeholder="Add to cart"
105+
className="rounded border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-sm"
106+
/>
107+
</label>
108+
<label className="flex flex-col gap-1 text-xs">
109+
<span className="text-[var(--color-muted)]">Must be</span>
110+
<select
111+
name="match"
112+
className="rounded border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-sm"
113+
>
114+
<option value="present">present</option>
115+
<option value="absent">absent</option>
116+
</select>
117+
</label>
118+
</div>
119+
)}
120+
121+
<div className="flex flex-wrap gap-3">
122+
<label className="flex flex-col gap-1 text-xs">
123+
<span className="text-[var(--color-muted)]">Interval (s)</span>
124+
<input
125+
name="intervalS"
126+
type="number"
127+
min={60}
128+
defaultValue={60}
129+
className="w-28 rounded border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-sm"
130+
/>
131+
</label>
132+
<label className="flex flex-1 flex-col gap-1 text-xs">
133+
<span className="text-[var(--color-muted)]">Alert email (defaults to yours)</span>
134+
<input
135+
name="alertEmail"
136+
type="email"
137+
placeholder="you@example.com"
138+
className="rounded border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-sm"
139+
/>
140+
</label>
141+
</div>
142+
143+
{error && <p className="text-xs text-[var(--color-fail)]">{error}</p>}
144+
145+
<div className="flex gap-2">
146+
<button
147+
type="submit"
148+
disabled={pending}
149+
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--color-accent-fg)] disabled:opacity-60"
150+
>
151+
{pending ? "Creating…" : "Create monitor"}
152+
</button>
153+
<button
154+
type="button"
155+
onClick={() => setOpen(false)}
156+
className="rounded-md px-3 py-1.5 text-sm text-[var(--color-muted)]"
157+
>
158+
Cancel
159+
</button>
160+
</div>
161+
</form>
162+
);
163+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"use client";
2+
3+
import { useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { setMonitorEnabled, deleteMonitor } from "@/app/actions/monitors";
6+
7+
export function MonitorActions({
8+
projectId,
9+
monitorId,
10+
enabled,
11+
name,
12+
}: {
13+
projectId: string;
14+
monitorId: string;
15+
enabled: boolean;
16+
name: string;
17+
}) {
18+
const router = useRouter();
19+
const [pending, start] = useTransition();
20+
21+
function toggle() {
22+
start(async () => {
23+
await setMonitorEnabled(projectId, monitorId, !enabled);
24+
router.refresh();
25+
});
26+
}
27+
function remove() {
28+
if (!window.confirm(`Delete monitor "${name}"?`)) return;
29+
start(async () => {
30+
await deleteMonitor(projectId, monitorId);
31+
router.refresh();
32+
});
33+
}
34+
35+
return (
36+
<div className="flex items-center gap-2 text-xs">
37+
<button
38+
onClick={toggle}
39+
disabled={pending}
40+
className="text-[var(--color-muted)] hover:underline disabled:opacity-60"
41+
>
42+
{enabled ? "Pause" : "Resume"}
43+
</button>
44+
<button
45+
onClick={remove}
46+
disabled={pending}
47+
className="text-[var(--color-fail)] hover:underline disabled:opacity-60"
48+
>
49+
Delete
50+
</button>
51+
</div>
52+
);
53+
}

0 commit comments

Comments
 (0)