diff --git a/web/app/monitors/[id]/page.tsx b/web/app/monitors/[id]/page.tsx
new file mode 100644
index 0000000..fd8aa31
--- /dev/null
+++ b/web/app/monitors/[id]/page.tsx
@@ -0,0 +1,236 @@
+"use client";
+
+import { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useParams, useRouter } from "next/navigation";
+import Link from "next/link";
+import { api, Monitor, MonitorStatus, Rollup } from "@/lib/api";
+import { Badge, Button, Card, CardHeader, CardTitle, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
+
+function statusVariant(status: MonitorStatus) {
+ switch (status) {
+ case "up":
+ return "success";
+ case "down":
+ return "danger";
+ default:
+ return "warning";
+ }
+}
+
+function uptimePct(rollups: Rollup[]): number {
+ const checks = rollups.reduce((a, r) => a + r.checks, 0);
+ const up = rollups.reduce((a, r) => a + r.up_count, 0);
+ return checks > 0 ? (up / checks) * 100 : 0;
+}
+
+function Heartbeat({ rollups }: { rollups: Rollup[] }) {
+ const recent = rollups.slice(-48);
+ return (
+
+ {recent.map((r) => {
+ const pct = r.checks > 0 ? (r.up_count / r.checks) * 100 : 0;
+ const color = r.checks === 0 ? "bg-surface-2" : pct >= 99 ? "bg-success" : pct >= 80 ? "bg-warning" : "bg-danger";
+ return (
+
+ );
+ })}
+ {recent.length === 0 &&
No history yet.}
+
+ );
+}
+
+export default function MonitorDetailPage() {
+ const params = useParams();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const monitorId = params.id as string;
+ const [confirmDelete, setConfirmDelete] = useState(false);
+
+ const { data: monitor, isLoading } = useQuery({
+ queryKey: ["monitors", monitorId],
+ queryFn: () => api.getMonitor(monitorId),
+ refetchInterval: 30_000,
+ });
+
+ const { data: rollups } = useQuery({
+ queryKey: ["monitors", monitorId, "uptime"],
+ queryFn: () => api.getMonitorUptime(monitorId),
+ refetchInterval: 60_000,
+ });
+
+ const { data: incidents } = useQuery({
+ queryKey: ["monitors", monitorId, "incidents"],
+ queryFn: () => api.getMonitorIncidents(monitorId),
+ refetchInterval: 60_000,
+ });
+
+ const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({
+ mutationFn: () => api.deleteMonitor(monitorId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["monitors"] });
+ router.push("/monitors");
+ },
+ });
+
+ const { mutate: toggleEnabled } = useMutation({
+ mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }),
+ });
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!monitor) {
+ return (
+
+ );
+ }
+
+ const all = rollups ?? [];
+ const last24 = all.slice(-24);
+
+ return (
+
+
+
+
+ ← Monitors
+
+
+
{monitor.name}
+ {monitor.state.status}
+ {monitor.type}
+ {!monitor.enabled && disabled}
+
+ {monitor.state.message &&
{monitor.state.message}
}
+
+
+
+ {!confirmDelete ? (
+
+ ) : (
+
+ Are you sure?
+
+
+
+ )}
+
+
+
+
+
+ Uptime (24h)
+ {uptimePct(last24).toFixed(1)}%
+
+
+ Uptime (30d)
+ {uptimePct(all).toFixed(1)}%
+
+
+ Latency
+ {monitor.state.latency_ms}ms
+
+
+ Cert expiry
+
+ {monitor.state.cert_expiry_at ? new Date(monitor.state.cert_expiry_at).toLocaleDateString() : "—"}
+
+
+
+
+
+
+ Heartbeat (last 48h)
+
+
+
+
+
+
+
+
Incidents
+
+ {!incidents || incidents.length === 0 ? (
+ No incidents recorded.
+ ) : (
+
+
+
+ | Started |
+ Resolved |
+ Cause |
+
+
+
+ {incidents.map((inc) => (
+
+ |
+ {new Date(inc.started_at).toLocaleString()}
+ |
+
+ {inc.resolved_at ? (
+ {new Date(inc.resolved_at).toLocaleString()}
+ ) : (
+ ongoing
+ )}
+ |
+
+ {inc.cause || "—"}
+ |
+
+ ))}
+
+
+ )}
+
+
+
+
+ Configuration
+
+
+
+
- Runner
+ - {monitor.runner}
+
+
+
- Interval
+ - {monitor.interval_sec}s
+
+
+
- Retries before down
+ - {monitor.retries}
+
+
+
- Target
+ -
+ {monitor.target.url || `${monitor.target.host ?? ""}${monitor.target.port ? `:${monitor.target.port}` : ""}`}
+
+
+
+
+
+
+ );
+}
diff --git a/web/app/monitors/new/page.tsx b/web/app/monitors/new/page.tsx
new file mode 100644
index 0000000..3f02ac3
--- /dev/null
+++ b/web/app/monitors/new/page.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { api, MonitorInput, MonitorType } from "@/lib/api";
+import { Button, Card } from "@/components/ui";
+
+const inputClass =
+ "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
+
+const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
+
+export default function NewMonitorPage() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const [name, setName] = useState("");
+ const [type, setType] = useState("http");
+ const [url, setUrl] = useState("");
+ const [host, setHost] = useState("");
+ const [port, setPort] = useState(443);
+ const [method, setMethod] = useState("GET");
+ const [expectedStatus, setExpectedStatus] = useState(200);
+ const [keyword, setKeyword] = useState("");
+ const [tlsWarnDays, setTlsWarnDays] = useState(14);
+ const [intervalSec, setIntervalSec] = useState(60);
+ const [retries, setRetries] = useState(1);
+ const [runner, setRunner] = useState("server");
+ const [enabled, setEnabled] = useState(true);
+
+ const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
+
+ const { mutate: create, isPending, error } = useMutation({
+ mutationFn: (input: MonitorInput) => api.createMonitor(input),
+ onSuccess: (m) => {
+ queryClient.invalidateQueries({ queryKey: ["monitors"] });
+ router.push(`/monitors/${m.monitor_id}`);
+ },
+ });
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const target: MonitorInput["target"] = {};
+ if (type === "http") {
+ target.url = url;
+ target.method = method;
+ target.expected_status = expectedStatus;
+ if (keyword) target.keyword = keyword;
+ } else if (type === "tls") {
+ target.host = host;
+ target.port = port || 443;
+ target.tls_warn_days = tlsWarnDays;
+ } else if (type === "icmp") {
+ target.host = host;
+ } else {
+ target.host = host;
+ target.port = port;
+ }
+ create({ name, type, target, interval_sec: intervalSec, retries, runner, enabled });
+ }
+
+ return (
+
+
+ ← Monitors
+
+
New Monitor
+
+
+
+
+
+ );
+}
diff --git a/web/app/monitors/page.tsx b/web/app/monitors/page.tsx
new file mode 100644
index 0000000..f958857
--- /dev/null
+++ b/web/app/monitors/page.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import Link from "next/link";
+import { api, Monitor, MonitorStatus } from "@/lib/api";
+import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
+
+function statusVariant(status: MonitorStatus) {
+ switch (status) {
+ case "up":
+ return "success";
+ case "down":
+ return "danger";
+ default:
+ return "warning";
+ }
+}
+
+function targetSummary(m: Monitor): string {
+ if (m.type === "http") return m.target.url ?? "";
+ if (m.type === "tls") return `${m.target.host ?? ""}:${m.target.port || 443}`;
+ if (m.type === "icmp") return m.target.host ?? "";
+ return `${m.target.host ?? ""}:${m.target.port ?? ""}`;
+}
+
+export default function MonitorsPage() {
+ const { data: monitors, isLoading } = useQuery({
+ queryKey: ["monitors"],
+ queryFn: () => api.listMonitors(),
+ refetchInterval: 30_000,
+ });
+
+ return (
+
+
+
+
Monitors
+
Service uptime and latency checks.
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : !monitors || monitors.length === 0 ? (
+
+
No monitors yet.
+
+
+
+
+ ) : (
+
+
+
+ | Name |
+ Type |
+ Target |
+ Status |
+ Latency |
+ Last check |
+
+
+
+ {monitors.map((m) => (
+
+ |
+
+ {m.name}
+
+ |
+
+ {m.type}
+ |
+
+ {targetSummary(m)}
+ |
+
+ {m.state.status}
+ |
+
+ {m.state.latency_ms}ms
+ |
+
+
+ {m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "—"}
+
+ |
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx
index 8f0e838..60d7520 100644
--- a/web/components/Sidebar.tsx
+++ b/web/components/Sidebar.tsx
@@ -60,6 +60,14 @@ function SettingsIcon() {
);
}
+function MonitorIcon() {
+ return (
+
+ );
+}
+
function StepsIcon() {
return (