feat: Updated monitors pages
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m25s

This commit is contained in:
2026-08-04 12:19:15 +01:00
parent 287bd9657b
commit 80f0afb28b
7 changed files with 1077 additions and 479 deletions
+53 -43
View File
@@ -4,55 +4,65 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, MonitorInput } from "@/lib/api";
import { Card } from "@/components/ui";
import { MonitorForm } from "@/components/monitors/MonitorForm";
export default function EditMonitorPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const monitorId = params.id as string;
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const monitorId = params.id as string;
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
queryFn: () => api.getMonitor(monitorId),
});
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
queryFn: () => api.getMonitor(monitorId),
});
const { mutate: update, isPending, error } = useMutation({
mutationFn: (input: MonitorInput) => api.updateMonitor(monitorId, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
router.push(`/monitors/${monitorId}`);
},
});
const {
mutate: update,
isPending,
error,
} = useMutation({
mutationFn: (input: MonitorInput) => api.updateMonitor(monitorId, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
router.push(`/monitors/${monitorId}`);
},
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (!monitor) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
That monitor no longer exists.{" "}
<Link href="/monitors" className="underline">
Back to monitors
</Link>
.
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
<div className="p-4 sm:p-6 lg:p-8">
<Link href={`/monitors/${monitorId}`} className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
{monitor.name}
</Link>
<div className="mb-5">
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Editing</p>
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">{monitor.name}</h1>
</div>
<MonitorForm initial={monitor} submitLabel="Save changes" onSubmit={update} isPending={isPending} error={error as Error | null} />
</div>
);
}
if (!monitor) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<Link href={`/monitors/${monitorId}`} className="text-sm text-text-secondary hover:text-text-primary">
{monitor.name}
</Link>
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">Edit Monitor</h1>
<Card className="max-w-2xl">
<MonitorForm initial={monitor} submitLabel="Save Changes" onSubmit={update} isPending={isPending} error={error as Error | null} />
</Card>
</div>
);
}
+310 -131
View File
@@ -4,36 +4,174 @@ 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";
import { api, Incident, Rollup } from "@/lib/api";
import { Button } from "@/components/ui";
import {
Slot,
StatusChip,
avgLatency,
buildSlots,
displayStatus,
formatDuration,
formatMs,
formatPct,
relativeTime,
statusStripe,
targetSummary,
uptimePct,
} from "@/components/monitors/MonitorVisuals";
function statusVariant(status: MonitorStatus) {
switch (status) {
case "up":
return "success";
case "down":
return "danger";
default:
return "warning";
}
/*
* Hourly uptime and response time share one time axis, because the question
* people actually arrive with is whether the slow hour and the failing hour
* are the same hour. The incidents below are the same story told in words.
*/
const CHART_W = 480;
const CHART_H = 158;
function niceCeiling(ms: number): number {
if (ms <= 0) return 100;
const steps = [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
return steps.find((s) => s >= ms) ?? Math.ceil(ms / 10000) * 10000;
}
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 History({ slots }: { slots: Slot[] }) {
const latencies = slots.map((s) => s.latency).filter((v): v is number => v !== null);
const scale = niceCeiling(Math.max(...latencies, 0) * 1.15);
/*
* The trace breaks over hours with no checks rather than drawing a
* straight line across them — a line implies data that was never
* collected.
*/
const segments: string[] = [];
let open: string[] = [];
slots.forEach((s, i) => {
if (s.latency === null) {
if (open.length > 1) segments.push(open.join(" "));
open = [];
return;
}
const x = (i / Math.max(slots.length - 1, 1)) * CHART_W;
const y = CHART_H - Math.min(s.latency / scale, 1) * CHART_H;
open.push(`${open.length === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`);
});
if (open.length > 1) segments.push(open.join(" "));
const firstAt = slots[0]?.at;
const midAt = slots[Math.floor(slots.length / 2)]?.at;
const tick = (d?: Date) => (d ? d.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" }) : "");
function Heartbeat({ rollups }: { rollups: Rollup[] }) {
const recent = rollups.slice(-48);
return (
<div className="flex items-end gap-0.5">
{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 <div key={r.period_start} className={`h-8 w-1.5 rounded-sm ${color}`} title={`${new Date(r.period_start).toLocaleString()} ${pct.toFixed(0)}% up`} />;
})}
{recent.length === 0 && <span className="text-xs text-text-secondary">No history yet.</span>}
<div>
<div className="relative h-[190px] overflow-hidden rounded-sm bg-well px-2.5 pb-6 pt-2.5">
{[1, 0.5].map((f) => (
<div key={f} className="pointer-events-none absolute left-2.5 right-2.5 border-t border-dashed border-border-soft" style={{ top: `${10 + (1 - f) * CHART_H}px` }}>
<span className="absolute right-0 -top-[11px] bg-well pl-1.5 font-mono text-[9px] text-text-tertiary">{formatMs(scale * f)}</span>
</div>
))}
<div className="absolute inset-x-2.5 bottom-6 top-2.5 flex items-end gap-0.5">
{slots.map((s) => (
<div
key={s.at.getTime()}
className="h-full flex-1"
title={s.pct === null ? "no checks ran" : `${s.pct.toFixed(1)}% up`}
style={{ display: "flex", alignItems: "flex-end" }}
>
<div
className={`w-full rounded-[1px] ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
style={{ height: s.pct === null ? "18%" : `${Math.max(s.pct, 12)}%` }}
/>
</div>
))}
</div>
<svg
className="absolute inset-x-2.5 bottom-6 top-2.5 h-[calc(100%-2rem)] w-[calc(100%-1.25rem)]"
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
preserveAspectRatio="none"
aria-hidden
>
{segments.map((d, i) => (
<path key={i} d={d} fill="none" stroke="rgb(var(--accent-rgb))" strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
))}
</svg>
<div className="absolute inset-x-2.5 bottom-1 flex justify-between font-mono text-[10px] text-text-tertiary">
<span>{tick(firstAt)}</span>
<span>{tick(midAt)}</span>
<span>now</span>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-[11.5px] text-text-secondary">
<span className="flex items-center gap-2">
<span className="block h-2.5 w-2.5 rounded-sm bg-success/60" /> Hourly uptime
</span>
<span className="flex items-center gap-2">
<span className="block h-0.5 w-3.5 bg-accent" /> Response time
</span>
<span className="text-text-tertiary">Gaps mean no checks ran</span>
</div>
</div>
);
}
function Figure({ label, value, unit, tone = "" }: { label: string; value: string; unit?: string; tone?: string }) {
return (
<div className="border-l border-border-soft px-5 py-3.5 first:border-l-0">
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{label}</p>
<p className={`mt-1 font-mono text-xl font-semibold tabular-nums tracking-tight ${tone || "text-text-primary"}`}>
{value}
{unit && <span className="text-xs font-normal text-text-tertiary">{unit}</span>}
</p>
</div>
);
}
function IncidentRow({ incident }: { incident: Incident }) {
const open = !incident.resolved_at;
const started = new Date(incident.started_at);
return (
<div className="grid grid-cols-[14px_minmax(0,1fr)] items-start gap-3 border-t border-border-soft px-5 py-3 first:border-t-0">
<span className={`mt-1.5 block h-2 w-2 rounded-full ${open ? "bg-danger ring-4 ring-danger/20" : "bg-text-tertiary"}`} />
<div className="min-w-0">
<p className="text-[13.5px] text-text-primary">{incident.cause || "Check failed"}</p>
<p className="mt-1 flex flex-wrap gap-2 font-mono text-[11px] text-text-tertiary">
<span>
{started.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}
{incident.resolved_at && `${new Date(incident.resolved_at).toLocaleString(undefined, { hour: "2-digit", minute: "2-digit" })}`}
</span>
<span className="text-border">·</span>
<span className="text-text-secondary">
{open ? "Ongoing, " : ""}
{formatDuration(incident.started_at, incident.resolved_at)}
</span>
</p>
</div>
</div>
);
}
function Panel({ title, aside, children, padded = true }: { title: string; aside?: string; children: React.ReactNode; padded?: boolean }) {
return (
<div className="rounded-lg border border-border bg-surface">
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
<h2 className="text-[15px] font-semibold text-text-primary">{title}</h2>
{aside && <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{aside}</span>}
</div>
<div className={padded ? "p-5" : ""}>{children}</div>
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-3 border-t border-border-soft py-2.5 first:border-t-0 first:pt-0">
<dt className="text-text-tertiary">{label}</dt>
<dd className="break-all text-right font-mono text-text-primary">{value}</dd>
</div>
);
}
@@ -63,6 +201,18 @@ export default function MonitorDetailPage() {
refetchInterval: 60_000,
});
const { data: channels } = useQuery({
queryKey: ["channels"],
queryFn: () => api.listChannels(),
enabled: (monitor?.channel_ids?.length ?? 0) > 0,
});
const { data: servers } = useQuery({
queryKey: ["servers"],
queryFn: () => api.listServers(),
enabled: !!monitor && monitor.runner !== "server",
});
const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({
mutationFn: () => api.deleteMonitor(monitorId),
onSuccess: () => {
@@ -71,7 +221,7 @@ export default function MonitorDetailPage() {
},
});
const { mutate: toggleEnabled } = useMutation({
const { mutate: toggleEnabled, isPending: isToggling } = useMutation({
mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }),
});
@@ -87,35 +237,57 @@ export default function MonitorDetailPage() {
if (!monitor) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
That monitor no longer exists.{" "}
<Link href="/monitors" className="underline">
Back to monitors
</Link>
.
</div>
</div>
);
}
const all = rollups ?? [];
const last24 = all.slice(-24);
const all: Rollup[] = rollups ?? [];
const slots = buildSlots(all);
const status = displayStatus(monitor);
const pct24 = uptimePct(all.slice(-24));
const pct30d = uptimePct(all);
const latency = monitor.state.latency_ms > 0 ? monitor.state.latency_ms : avgLatency(all.slice(-1));
const certDays = monitor.state.cert_expiry_at
? Math.round((new Date(monitor.state.cert_expiry_at).getTime() - Date.now()) / 86400_000)
: null;
const openIncidents = (incidents ?? []).filter((i) => !i.resolved_at).length;
const runnerName =
monitor.runner === "server" ? "Control plane" : servers?.find((s) => s.server_id === monitor.runner)?.hostname || monitor.runner;
const alertChannels = (monitor.channel_ids ?? []).map((id) => channels?.find((c) => c.channel_id === id));
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<Link href="/monitors" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
<div className="mt-2 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{monitor.name}</h1>
<Badge variant={statusVariant(monitor.state.status)}>{monitor.state.status}</Badge>
<Badge variant="neutral">{monitor.type}</Badge>
{!monitor.enabled && <Badge variant="warning">disabled</Badge>}
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-2xl font-bold tracking-tight text-text-primary">{monitor.name}</h1>
<StatusChip status={status} />
<span className="rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-secondary">
{monitor.type}
</span>
</div>
{monitor.state.message && <p className="mt-1 text-sm text-text-secondary">{monitor.state.message}</p>}
<p className="mt-1.5 break-all font-mono text-xs text-text-tertiary">{targetSummary(monitor)}</p>
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
</div>
<div className="flex flex-wrap gap-2">
<Link href={`/monitors/${monitorId}/edit`}>
<Button variant="secondary">Edit</Button>
</Link>
<Button variant="secondary" onClick={() => toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Disable" : "Enable"}
<Button variant="secondary" loading={isToggling} onClick={() => toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Pause checks" : "Resume checks"}
</Button>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
@@ -123,108 +295,115 @@ export default function MonitorDetailPage() {
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">Are you sure?</span>
<span className="text-sm text-danger">Delete this monitor and its history?</span>
<Button variant="danger" loading={isDeleting} onClick={() => deleteMonitor()}>
Confirm
Delete
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
Keep
</Button>
</div>
)}
</div>
</div>
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<p className="text-xs text-text-secondary">Uptime (24h)</p>
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(last24).toFixed(1)}%</p>
</Card>
<Card>
<p className="text-xs text-text-secondary">Uptime (30d)</p>
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(all).toFixed(1)}%</p>
</Card>
<Card>
<p className="text-xs text-text-secondary">Latency</p>
<p className="mt-1 text-2xl font-bold text-text-primary">{monitor.state.latency_ms}ms</p>
</Card>
<Card>
<p className="text-xs text-text-secondary">Cert expiry</p>
<p className="mt-1 text-sm font-medium text-text-primary">{monitor.state.cert_expiry_at ? new Date(monitor.state.cert_expiry_at).toLocaleDateString() : "n/a"}</p>
</Card>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Heartbeat (last 48h)</CardTitle>
</CardHeader>
<Heartbeat rollups={all} />
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card padding={false}>
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Incidents</h2>
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-border bg-surface">
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
<h2 className="text-[15px] font-semibold text-text-primary">Last 48 hours</h2>
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">1 hour per bar</span>
</div>
<div className="p-5">
<History slots={slots} />
</div>
<div className="grid grid-cols-2 border-t border-border-soft sm:grid-cols-4">
<Figure
label="Uptime 24h"
value={formatPct(pct24)}
unit={pct24 !== null ? "%" : undefined}
tone={pct24 !== null && pct24 < 99 ? "text-danger" : ""}
/>
<Figure label="Uptime 30d" value={formatPct(pct30d)} unit={pct30d !== null ? "%" : undefined} />
<Figure
label="Response now"
value={status === "down" ? "—" : formatMs(latency)}
tone={status === "down" ? "text-danger" : ""}
/>
<Figure
label="Certificate"
value={certDays === null ? "n/a" : `${certDays}`}
unit={certDays === null ? undefined : certDays === 1 ? " day left" : " days left"}
tone={certDays !== null && certDays <= (monitor.target.tls_warn_days ?? 14) ? "text-warning" : ""}
/>
</div>
</div>
{!incidents || incidents.length === 0 ? (
<div className="py-12 text-center text-sm text-text-secondary">No incidents recorded.</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Started</Th>
<Th>Resolved</Th>
<Th>Cause</Th>
</Tr>
</Thead>
<Tbody>
{incidents.map((inc) => (
<Tr key={inc.incident_id}>
<Td label="Started">
<span className="text-xs text-text-secondary">{new Date(inc.started_at).toLocaleString()}</span>
</Td>
<Td label="Resolved">
{inc.resolved_at ? (
<span className="text-xs text-text-secondary">{new Date(inc.resolved_at).toLocaleString()}</span>
) : (
<Badge variant="danger">ongoing</Badge>
)}
</Td>
<Td label="Cause">
<span className="text-xs text-text-primary">{inc.cause || "n/a"}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Runner</dt>
<dd className="mt-0.5 font-mono text-text-primary">{monitor.runner}</dd>
<Panel
title="Incidents"
aside={incidents && incidents.length > 0 ? `${incidents.length} recorded${openIncidents ? ` · ${openIncidents} open` : ""}` : undefined}
padded={false}
>
{!incidents || incidents.length === 0 ? (
<p className="px-5 py-12 text-center text-sm text-text-secondary">
No incidents recorded. Every check has passed since this monitor was created.
</p>
) : (
incidents.map((inc) => <IncidentRow key={inc.incident_id} incident={inc} />)
)}
</Panel>
</div>
<div className="flex flex-col gap-5">
<Panel title="Check">
<dl className="text-[13px]">
<Row label="Runs from" value={runnerName} />
<Row label="Every" value={`${monitor.interval_sec} s`} />
<Row label="Fails after" value={`${monitor.retries} ${monitor.retries === 1 ? "try" : "tries"}`} />
{monitor.type === "http" && monitor.target.method && <Row label="Method" value={monitor.target.method} />}
{monitor.type === "http" && monitor.target.expected_status && (
<Row label="Expects" value={monitor.target.expected_status} />
)}
{monitor.target.keyword && <Row label="Body contains" value={monitor.target.keyword} />}
{monitor.type === "tls" && <Row label="Warns at" value={`${monitor.target.tls_warn_days ?? 14} days`} />}
<Row label="Last checked" value={relativeTime(monitor.state.last_check_at)} />
</dl>
</Panel>
<Panel title="Alerts">
{alertChannels.length === 0 ? (
<p className="text-sm text-text-secondary">No one is told when this check fails.</p>
) : (
<dl className="text-[13px]">
{alertChannels.map((c, i) => (
<Row
key={monitor.channel_ids?.[i]}
label={c ? c.type : "channel"}
value={c ? c.name : "Removed channel"}
/>
))}
</dl>
)}
<Link href="/settings/notifications">
<Button variant="secondary" size="sm" className="mt-4">
Manage channels
</Button>
</Link>
</Panel>
<div className="rounded-lg border border-border bg-surface px-5 py-4">
<div className="flex items-center gap-2.5">
<span className={`block h-2 w-2 rounded-full ${statusStripe[status]}`} />
<p className="text-[13px] text-text-secondary">
{status === "paused"
? "Paused — no checks are running."
: status === "down"
? `Failing for ${monitor.state.fails} consecutive ${monitor.state.fails === 1 ? "check" : "checks"}.`
: `Checked ${relativeTime(monitor.state.last_check_at)}.`}
</p>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Interval</dt>
<dd className="mt-0.5 text-text-primary">{monitor.interval_sec}s</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Retries before down</dt>
<dd className="mt-0.5 text-text-primary">{monitor.retries}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Target</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">
{monitor.target.url || `${monitor.target.host ?? ""}${monitor.target.port ? `:${monitor.target.port}` : ""}`}
</dd>
</div>
</dl>
</Card>
</div>
</div>
</div>
</div>
);
+25 -21
View File
@@ -4,31 +4,35 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api, MonitorInput } from "@/lib/api";
import { Card } from "@/components/ui";
import { MonitorForm } from "@/components/monitors/MonitorForm";
export default function NewMonitorPage() {
const router = useRouter();
const queryClient = useQueryClient();
const router = useRouter();
const queryClient = useQueryClient();
const { mutate: create, isPending, error } = useMutation({
mutationFn: (input: MonitorInput) => api.createMonitor(input),
onSuccess: (m) => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
router.push(`/monitors/${m.monitor_id}`);
},
});
const {
mutate: create,
isPending,
error,
} = useMutation({
mutationFn: (input: MonitorInput) => api.createMonitor(input),
onSuccess: (m) => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
router.push(`/monitors/${m.monitor_id}`);
},
});
return (
<div className="p-4 sm:p-6 lg:p-8">
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">New Monitor</h1>
return (
<div className="p-4 sm:p-6 lg:p-8">
<Link href="/monitors" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
<div className="mb-5">
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">New check</p>
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">Watch something</h1>
</div>
<Card className="max-w-2xl">
<MonitorForm submitLabel="Create Monitor" onSubmit={create} isPending={isPending} error={error as Error | null} />
</Card>
</div>
);
<MonitorForm submitLabel="Create monitor" onSubmit={create} isPending={isPending} error={error as Error | null} />
</div>
);
}
+177 -96
View File
@@ -1,109 +1,190 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useQueries, 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";
import { api, Monitor, Rollup } from "@/lib/api";
import { Button } from "@/components/ui";
import {
DisplayStatus,
StatusChip,
Tape,
avgLatency,
buildSlots,
displayStatus,
formatMs,
formatPct,
relativeTime,
statusStripe,
targetSummary,
uptimePct,
} from "@/components/monitors/MonitorVisuals";
function statusVariant(status: MonitorStatus) {
switch (status) {
case "up":
return "success";
case "down":
return "danger";
default:
return "warning";
}
/*
* The list is a stack of channel strips rather than a table. Each row carries
* 48 hours of history, so "is this healthy" is answered by the shape of the
* tape and not by one status cell that was true when the page rendered.
*
* Uptime is per monitor, so the rollups are fetched per monitor. A fleet is
* tens of checks, not thousands, and the alternative is a list endpoint that
* embeds history for every row whether or not anyone looks at it.
*/
const ROW = "grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1.15fr)_minmax(0,2fr)_170px] sm:gap-5";
function FleetMeter({ counts, uptime }: { counts: Record<DisplayStatus, number>; uptime: number | null }) {
const total = counts.up + counts.down + counts.pending + counts.paused;
const order: DisplayStatus[] = ["up", "pending", "down", "paused"];
return (
<div className="rounded-lg border border-border bg-surface p-4 sm:px-5">
<div className="mb-3 flex flex-wrap items-baseline gap-x-6 gap-y-2">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Fleet uptime, last 24 hours</p>
<p className="mt-0.5 font-mono text-3xl font-semibold tabular-nums tracking-tight text-text-primary">
{formatPct(uptime)}
{uptime !== null && <span className="text-base text-text-tertiary">%</span>}
</p>
</div>
<div className="flex flex-wrap gap-4 sm:ml-auto">
{order.map((s) => (
<span key={s} className="flex items-center gap-2 text-xs text-text-secondary">
<span className={`block h-2.5 w-2.5 rounded-sm ${statusStripe[s]}`} />
<span className="font-mono tabular-nums text-text-primary">{counts[s]}</span>
{s === "pending" ? "pending" : s}
</span>
))}
</div>
</div>
<div
className="flex h-2 gap-0.5 overflow-hidden rounded-sm bg-well"
role="img"
aria-label={`${counts.up} up, ${counts.pending} pending, ${counts.down} down, ${counts.paused} paused of ${total} monitors`}
>
{order.map((s) =>
counts[s] > 0 ? <span key={s} className={statusStripe[s]} style={{ flex: counts[s] }} /> : null,
)}
</div>
</div>
);
}
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 ?? ""}`;
function MonitorRow({ monitor, rollups }: { monitor: Monitor; rollups: Rollup[] }) {
const status = displayStatus(monitor);
const slots = buildSlots(rollups);
const pct = uptimePct(rollups.slice(-24));
const latency = monitor.state.latency_ms > 0 ? monitor.state.latency_ms : avgLatency(rollups.slice(-1));
return (
<Link
href={`/monitors/${monitor.monitor_id}`}
className={`${ROW} relative items-center border-t border-border-soft px-4 py-3.5 transition-colors first:border-t-0 hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5`}
>
<span className={`absolute inset-y-0 left-0 w-0.5 ${statusStripe[status]}`} aria-hidden />
<div className="min-w-0">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-text-primary">{monitor.name}</span>
<span className="shrink-0 rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-secondary">
{monitor.type}
</span>
</span>
<span className="mt-1 block truncate font-mono text-[11.5px] text-text-tertiary">{targetSummary(monitor)}</span>
</div>
<Tape slots={slots} live={monitor.enabled} />
<div className="flex items-baseline gap-3 sm:block sm:text-right">
<p className={`font-mono text-lg font-semibold tabular-nums ${status === "down" ? "text-danger" : "text-text-primary"}`}>
{formatPct(pct)}
{pct !== null && <span className="text-xs text-text-tertiary">%</span>}
</p>
<p className="font-mono text-[11px] text-text-tertiary sm:mt-1">
<StatusChip status={status} className="mr-1.5 align-middle" />
{status === "paused" ? "no checks" : `${formatMs(latency)} · ${relativeTime(monitor.state.last_check_at)}`}
</p>
</div>
</Link>
);
}
export default function MonitorsPage() {
const { data: monitors, isLoading } = useQuery({
queryKey: ["monitors"],
queryFn: () => api.listMonitors(),
refetchInterval: 30_000,
});
const { data: monitors, isLoading } = useQuery({
queryKey: ["monitors"],
queryFn: () => api.listMonitors(),
refetchInterval: 30_000,
});
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Monitors</h1>
<p className="mt-1 text-sm text-text-secondary">Service uptime and latency checks.</p>
</div>
<div className="flex gap-2">
<Link href="/settings/notifications">
<Button variant="secondary">Notifications</Button>
</Link>
<Link href="/monitors/new">
<Button variant="primary">New Monitor</Button>
</Link>
</div>
</div>
const uptimeQueries = useQueries({
queries: (monitors ?? []).map((m) => ({
queryKey: ["monitors", m.monitor_id, "uptime"],
queryFn: () => api.getMonitorUptime(m.monitor_id),
refetchInterval: 60_000,
})),
});
<Card padding={false}>
{isLoading ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : !monitors || monitors.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-text-secondary">No monitors yet.</p>
<Link href="/monitors/new">
<Button variant="secondary" size="sm" className="mt-3">
Create your first monitor
</Button>
</Link>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th>Type</Th>
<Th>Target</Th>
<Th>Status</Th>
<Th>Latency</Th>
<Th>Last check</Th>
</Tr>
</Thead>
<Tbody>
{monitors.map((m) => (
<Tr key={m.monitor_id}>
<Td label="Name">
<Link href={`/monitors/${m.monitor_id}`} className="font-medium text-text-primary hover:text-accent">
{m.name}
const counts: Record<DisplayStatus, number> = { up: 0, down: 0, pending: 0, paused: 0 };
for (const m of monitors ?? []) counts[displayStatus(m)] += 1;
const fleetRollups = uptimeQueries.flatMap((q) => (q.data ?? []).slice(-24));
const fleetUptime = uptimePct(fleetRollups);
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
Fleet · {monitors?.length ?? 0} {monitors?.length === 1 ? "check" : "checks"}
</p>
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">Monitors</h1>
</div>
<div className="flex gap-2">
<Link href="/settings/notifications">
<Button variant="secondary">Notification channels</Button>
</Link>
</Td>
<Td label="Type">
<Badge variant="neutral">{m.type}</Badge>
</Td>
<Td label="Target">
<span className="font-mono text-xs text-text-secondary">{targetSummary(m)}</span>
</Td>
<Td label="Status">
<Badge variant={statusVariant(m.state.status)}>{m.state.status}</Badge>
</Td>
<Td label="Latency">
<span className="text-sm text-text-secondary">{m.state.latency_ms}ms</span>
</Td>
<Td label="Last check">
<span className="text-xs text-text-secondary">
{m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "n/a"}
</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
);
<Link href="/monitors/new">
<Button variant="primary">New monitor</Button>
</Link>
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : !monitors || monitors.length === 0 ? (
<div className="rounded-lg border border-border bg-surface px-6 py-14 text-center">
<p className="text-[15px] font-semibold text-text-primary">Nothing is being watched yet.</p>
<p className="mx-auto mt-2 max-w-[46ch] text-sm text-text-secondary">
Add a check and Vantage records uptime and response time on your interval, opens an incident when it fails, and tells the
channels you pick.
</p>
<Link href="/monitors/new">
<Button variant="primary" className="mt-4">
Add your first check
</Button>
</Link>
</div>
) : (
<>
<FleetMeter counts={counts} uptime={fleetUptime} />
<div className={`${ROW} hidden px-5 pb-2 pt-5 sm:grid`}>
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Monitor</p>
<div className="flex items-end justify-between font-mono text-[10px] tracking-[0.08em] text-text-tertiary">
<span>48h ago</span>
<span>24h</span>
<span>now</span>
</div>
<p className="text-right font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Uptime 24h · response</p>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-surface">
{monitors.map((m, i) => (
<MonitorRow key={m.monitor_id} monitor={m} rollups={uptimeQueries[i]?.data ?? []} />
))}
</div>
</>
)}
</div>
);
}
+317 -187
View File
@@ -5,211 +5,341 @@ import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Monitor, MonitorInput, MonitorType } from "@/lib/api";
import { Button } from "@/components/ui";
import { formatDuration } from "@/components/monitors/MonitorVisuals";
/*
* The form is grouped the way the detail page reads it back: what is checked,
* how often, and who hears about it. The section names are the same words on
* both screens — someone editing "Fails after" should recognise the number
* they saw under "Fails after".
*/
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";
"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 typeCopy: Record<MonitorType, { title: string; blurb: string; target: string }> = {
http: { title: "HTTP", blurb: "Requests a URL and checks the response", target: "URL" },
tcp: { title: "TCP", blurb: "Opens a socket on a host and port", target: "Host and port" },
icmp: { title: "Ping", blurb: "Pings a host and measures round trip", target: "Host" },
tls: { title: "TLS", blurb: "Reads a certificate and counts days left", target: "Host and port" },
};
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<section className="rounded-lg border border-border bg-surface">
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
<h2 className="text-[15px] font-semibold text-text-primary">{title}</h2>
{hint && <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{hint}</span>}
</div>
<div className="flex flex-col gap-4 p-5">{children}</div>
</section>
);
}
function Field({ label, help, children }: { label: string; help?: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</span>
{children}
{help && <span className="mt-1.5 block text-xs text-text-tertiary">{help}</span>}
</label>
);
}
function Check({
checked,
onChange,
title,
detail,
}: {
checked: boolean;
onChange: (v: boolean) => void;
title: React.ReactNode;
detail?: React.ReactNode;
}) {
return (
<label
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 transition-colors ${
checked ? "border-accent/50 bg-accent/5" : "border-border bg-surface-2 hover:border-accent/30"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-accent"
/>
<span className="min-w-0">
<span className="block text-sm text-text-primary">{title}</span>
{detail && <span className="mt-0.5 block text-xs text-text-tertiary">{detail}</span>}
</span>
</label>
);
}
export function MonitorForm({
initial,
submitLabel,
onSubmit,
isPending,
error,
initial,
submitLabel,
onSubmit,
isPending,
error,
}: {
initial?: Monitor;
submitLabel: string;
onSubmit: (input: MonitorInput) => void;
isPending: boolean;
error?: Error | null;
initial?: Monitor;
submitLabel: string;
onSubmit: (input: MonitorInput) => void;
isPending: boolean;
error?: Error | null;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState<MonitorType>(initial?.type ?? "http");
const [url, setUrl] = useState(initial?.target.url ?? "");
const [host, setHost] = useState(initial?.target.host ?? "");
const [port, setPort] = useState<number>(initial?.target.port ?? 443);
const [method, setMethod] = useState(initial?.target.method ?? "GET");
const [expectedStatus, setExpectedStatus] = useState<number>(initial?.target.expected_status ?? 200);
const [keyword, setKeyword] = useState(initial?.target.keyword ?? "");
const [tlsWarnDays, setTlsWarnDays] = useState<number>(initial?.target.tls_warn_days ?? 14);
const [insecure, setInsecure] = useState<boolean>(initial?.target.insecure ?? false);
const [intervalSec, setIntervalSec] = useState<number>(initial?.interval_sec ?? 60);
const [retries, setRetries] = useState<number>(initial?.retries ?? 1);
const [runner, setRunner] = useState(initial?.runner ?? "server");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState<MonitorType>(initial?.type ?? "http");
const [url, setUrl] = useState(initial?.target.url ?? "");
const [host, setHost] = useState(initial?.target.host ?? "");
const [port, setPort] = useState<number>(initial?.target.port ?? 443);
const [method, setMethod] = useState(initial?.target.method ?? "GET");
const [expectedStatus, setExpectedStatus] = useState<number>(initial?.target.expected_status ?? 200);
const [keyword, setKeyword] = useState(initial?.target.keyword ?? "");
const [tlsWarnDays, setTlsWarnDays] = useState<number>(initial?.target.tls_warn_days ?? 14);
const [insecure, setInsecure] = useState<boolean>(initial?.target.insecure ?? false);
const [intervalSec, setIntervalSec] = useState<number>(initial?.interval_sec ?? 60);
const [retries, setRetries] = useState<number>(initial?.retries ?? 1);
const [runner, setRunner] = useState(initial?.runner ?? "server");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
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;
target.insecure = insecure;
} 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;
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;
target.insecure = insecure;
} 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;
}
onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
}
onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className={labelClass}>Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. API health" required />
</div>
const runnerName = runner === "server" ? "the control plane" : servers?.find((s) => s.server_id === runner)?.hostname || "an agent";
/* Retries are consecutive failures, so the delay before "down" is one
interval per remaining try after the first. Say it in minutes, because
that is the number people are actually choosing between. */
const downAfter = formatDuration(new Date(Date.now() - intervalSec * Math.max(retries, 1) * 1000).toISOString());
<div>
<label className={labelClass}>Type</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{(["http", "tcp", "icmp", "tls"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
type === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t.toUpperCase()}
</button>
))}
</div>
</div>
return (
<form onSubmit={handleSubmit} className="flex max-w-3xl flex-col gap-5">
<Section title="Check" hint={typeCopy[type].target}>
<Field label="Name" help="Shown in the fleet list and in every alert this check sends.">
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing API" required />
</Field>
{type === "http" && (
<>
<div>
<label className={labelClass}>URL</label>
<input className={inputClass} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/health" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Method</label>
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
<option>GET</option>
<option>HEAD</option>
</select>
</div>
<div>
<label className={labelClass}>Expected status</label>
<input type="number" className={inputClass} value={expectedStatus} onChange={(e) => setExpectedStatus(Number(e.target.value))} />
</div>
</div>
<div>
<label className={labelClass}>Keyword (optional, body must contain)</label>
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="e.g. ok" />
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={insecure} onChange={(e) => setInsecure(e.target.checked)} />
Ignore TLS certificate errors (self-signed / expired)
</label>
</>
)}
<div>
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Kind</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{(Object.keys(typeCopy) as MonitorType[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
aria-pressed={type === t}
className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
type === t
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40"
}`}
>
<span
className={`block font-mono text-[11px] uppercase tracking-[0.1em] ${type === t ? "text-accent" : "text-text-secondary"}`}
>
{typeCopy[t].title}
</span>
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t].blurb}</span>
</button>
))}
</div>
</div>
{(type === "tcp" || type === "tls" || type === "icmp") && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Host</label>
<input className={inputClass} value={host} onChange={(e) => setHost(e.target.value)} placeholder="example.com" required />
</div>
{type !== "icmp" && (
<div>
<label className={labelClass}>Port</label>
<input type="number" className={inputClass} value={port} onChange={(e) => setPort(Number(e.target.value))} />
</div>
)}
</div>
)}
{type === "http" && (
<>
<Field label="URL">
<input
className={`${inputClass} font-mono`}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/healthz"
required
/>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Method">
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
<option>GET</option>
<option>HEAD</option>
</select>
</Field>
<Field label="Expected status">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={expectedStatus}
onChange={(e) => setExpectedStatus(Number(e.target.value))}
/>
</Field>
</div>
<Field label="Body must contain" help="Optional. The check fails if the response body is missing this text.">
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="ok" />
</Field>
<Check
checked={insecure}
onChange={setInsecure}
title="Accept any certificate"
detail="Use for self-signed or expired certificates. The check stops reporting TLS problems."
/>
</>
)}
{type === "tls" && (
<div>
<label className={labelClass}>Warn days before expiry</label>
<input type="number" className={inputClass} value={tlsWarnDays} onChange={(e) => setTlsWarnDays(Number(e.target.value))} />
</div>
)}
{(type === "tcp" || type === "tls" || type === "icmp") && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Host">
<input
className={`${inputClass} font-mono`}
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="example.com"
required
/>
</Field>
{type !== "icmp" && (
<Field label="Port">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={port}
onChange={(e) => setPort(Number(e.target.value))}
/>
</Field>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Interval (seconds)</label>
<input type="number" className={inputClass} value={intervalSec} onChange={(e) => setIntervalSec(Number(e.target.value))} min={10} />
</div>
<div>
<label className={labelClass}>Retries before down</label>
<input type="number" className={inputClass} value={retries} onChange={(e) => setRetries(Number(e.target.value))} min={1} />
</div>
</div>
{type === "tls" && (
<Field label="Warn this many days before expiry">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={tlsWarnDays}
onChange={(e) => setTlsWarnDays(Number(e.target.value))}
min={1}
/>
</Field>
)}
</Section>
<div>
<label className={labelClass}>Runner</label>
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
<option value="server">Server (central)</option>
{servers?.map((s) => (
<option key={s.server_id} value={s.server_id}>
Agent · {s.hostname}
</option>
))}
</select>
<p className="mt-1 text-xs text-text-tertiary">Agent-run monitors require the agent monitor scheduler (P2).</p>
</div>
<Section title="Schedule">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Run every" help="Seconds between checks. Minimum 10.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value))}
min={10}
/>
</Field>
<Field label="Fails after" help="Consecutive failures before an incident opens.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={retries}
onChange={(e) => setRetries(Number(e.target.value))}
min={1}
/>
</Field>
</div>
<div>
<label className={labelClass}>Notification channels</label>
{!channels || channels.length === 0 ? (
<p className="text-xs text-text-tertiary">
No channels yet.{" "}
<Link href="/settings/notifications" className="text-accent hover:underline">
Add one
</Link>
.
</p>
) : (
<div className="space-y-1.5">
{channels.map((ch) => (
<label key={ch.channel_id} className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={channelIds.includes(ch.channel_id)}
onChange={(e) =>
setChannelIds((prev) => (e.target.checked ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
}
<Field
label="Runs from"
help="Pick an agent for anything only reachable from inside that network. Everything else runs centrally."
>
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
<option value="server">Control plane</option>
{servers?.map((s) => (
<option key={s.server_id} value={s.server_id}>
Agent · {s.hostname}
</option>
))}
</select>
</Field>
<p className="rounded-lg bg-well px-4 py-3 font-mono text-[11.5px] leading-relaxed text-text-secondary">
Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "}
{retries === 1 ? "failure" : "consecutive failures"} roughly {downAfter}.
</p>
</Section>
<Section title="Alerts" hint={channelIds.length > 0 ? `${channelIds.length} selected` : undefined}>
{!channels || channels.length === 0 ? (
<p className="text-sm text-text-secondary">
No channels exist yet, so nobody will be told when this check fails.{" "}
<Link href="/settings/notifications" className="text-accent hover:underline">
Add a channel
</Link>
.
</p>
) : (
<div className="flex flex-col gap-2">
{channels.map((ch) => (
<Check
key={ch.channel_id}
checked={channelIds.includes(ch.channel_id)}
onChange={(v) =>
setChannelIds((prev) => (v ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
}
title={ch.name}
detail={
<span className="font-mono uppercase tracking-[0.1em]">
{ch.type}
{!ch.enabled && " · disabled, sends nothing"}
</span>
}
/>
))}
</div>
)}
<Check
checked={enabled}
onChange={setEnabled}
title="Start checking straight away"
detail="Turn this off to save the monitor without running it. You can resume it any time."
/>
{ch.name} <span className="text-text-tertiary">({ch.type})</span>
</label>
))}
</div>
)}
</div>
</Section>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enabled
</label>
{error && (
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error.message}</p>
)}
{error && <p className="text-sm text-danger">{error.message}</p>}
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending}>
{submitLabel}
</Button>
<Link href="/monitors">
<Button type="button" variant="ghost">
Cancel
</Button>
</Link>
</div>
</form>
);
<div className="flex gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{submitLabel}
</Button>
<Link href="/monitors">
<Button type="button" variant="ghost">
Cancel
</Button>
</Link>
</div>
</form>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { Monitor, MonitorStatus, Rollup } from "@/lib/api";
/*
* Shared vocabulary for the monitors screens.
*
* State is never carried by colour alone: every chip pairs a distinct shape
* with a text label, and every row carries a stripe on its left edge. Colour
* is the third signal, not the only one.
*/
export type DisplayStatus = MonitorStatus | "paused";
export function displayStatus(m: Monitor): DisplayStatus {
return m.enabled ? m.state.status : "paused";
}
export const statusLabel: Record<DisplayStatus, string> = {
up: "Up",
down: "Down",
pending: "Pending",
paused: "Paused",
};
/** The stripe colour for a row or panel edge. */
export const statusStripe: Record<DisplayStatus, string> = {
up: "bg-success",
down: "bg-danger",
pending: "bg-warning",
paused: "bg-text-tertiary",
};
const chipTone: Record<DisplayStatus, string> = {
up: "text-success border-success/35 bg-success/10",
down: "text-danger border-danger/40 bg-danger/10",
pending: "text-warning border-warning/35 bg-warning/10",
paused: "text-text-tertiary border-border",
};
/** Shape is the primary signal — round for up, triangle for down, and so on. */
function StatusMark({ status }: { status: DisplayStatus }) {
if (status === "up") return <span className="block h-[7px] w-[7px] rounded-full bg-success" />;
if (status === "down") return <span className="block h-0 w-0 border-x-[4px] border-b-[7px] border-x-transparent border-b-danger" />;
if (status === "pending") return <span className="block h-[6px] w-[6px] rotate-45 bg-warning" />;
return <span className="block h-[2px] w-[7px] bg-text-tertiary" />;
}
export function StatusChip({ status, className = "" }: { status: DisplayStatus; className?: string }) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-[11px] uppercase tracking-[0.08em] ${chipTone[status]} ${className}`}
>
<StatusMark status={status} />
{statusLabel[status]}
</span>
);
}
/* ---------------------------------------------------------------- numbers */
export function uptimePct(rollups: Rollup[]): number | null {
const checks = rollups.reduce((a, r) => a + r.checks, 0);
if (checks === 0) return null;
return (rollups.reduce((a, r) => a + r.up_count, 0) / checks) * 100;
}
export function avgLatency(rollups: Rollup[]): number | null {
const checks = rollups.reduce((a, r) => a + r.checks, 0);
if (checks === 0) return null;
return rollups.reduce((a, r) => a + r.sum_latency, 0) / checks;
}
export function formatPct(pct: number | null): string {
if (pct === null) return "—";
return pct >= 99.995 ? "100" : pct.toFixed(2);
}
export function formatMs(ms: number | null): string {
if (ms === null) return "—";
if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
return `${Math.round(ms)} ms`;
}
export 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 function relativeTime(iso?: string): string {
if (!iso) return "never";
const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${Math.max(secs, 0)}s ago`;
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
return `${Math.round(secs / 86400)}d ago`;
}
export function formatDuration(fromIso: string, toIso?: string): string {
const mins = Math.max(Math.round((new Date(toIso ?? Date.now()).getTime() - new Date(fromIso).getTime()) / 60000), 0);
if (mins < 60) return `${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ${mins % 60}m`;
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
}
/* ------------------------------------------------------------------- tape */
export interface Slot {
/** Start of the hour this slot covers. */
at: Date;
/** Percentage of checks that passed, or null when no check ran. */
pct: number | null;
checks: number;
latency: number | null;
}
/**
* Bucket rollups into the last `hours` whole hours, ending with the hour in
* progress. Rollups are sparse — an hour the scheduler never reached has no
* document at all — so the slots are built from the clock and filled in, not
* read off the array. A missing hour has to read as a gap rather than
* silently shifting the rest of the tape sideways.
*/
export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
const byHour = new Map<number, Rollup>();
for (const r of rollups) {
const t = new Date(r.period_start);
t.setMinutes(0, 0, 0);
byHour.set(t.getTime(), r);
}
const cursor = new Date();
cursor.setMinutes(0, 0, 0);
const slots: Slot[] = [];
for (let i = hours - 1; i >= 0; i--) {
const at = new Date(cursor.getTime() - i * 3600_000);
const r = byHour.get(at.getTime());
slots.push({
at,
checks: r?.checks ?? 0,
pct: r && r.checks > 0 ? (r.up_count / r.checks) * 100 : null,
latency: r && r.checks > 0 ? r.sum_latency / r.checks : null,
});
}
return slots;
}
function slotColor(s: Slot): string {
if (s.pct === null) return "bg-border-soft";
if (s.pct >= 99.5) return "bg-success";
if (s.pct >= 80) return "bg-warning";
return "bg-danger";
}
function slotHeight(s: Slot): number {
if (s.pct === null) return 26;
if (s.pct < 80) return 100;
return 55 + (s.pct - 80) * 2.2;
}
function slotTitle(s: Slot): string {
const when = s.at.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" });
if (s.pct === null) return `${when} · no checks ran`;
return `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
}
/**
* The tape: 48 hours of history as one strip. This is the spine of both
* screens — a row shows what has been happening, not only what is true this
* second.
*/
export function Tape({ slots, height = "h-9", live = true }: { slots: Slot[]; height?: string; live?: boolean }) {
const covered = slots.filter((s) => s.checks > 0).length;
return (
<div className={`relative flex ${height} items-end gap-px rounded-sm bg-well p-[3px]`}>
{slots.map((s) => (
<div key={s.at.getTime()} className="flex h-full flex-1 items-end" title={slotTitle(s)}>
<div className={`w-full rounded-[1px] ${slotColor(s)}`} style={{ height: `${slotHeight(s)}%` }} />
</div>
))}
{live && <span className="absolute inset-y-0 right-0 w-px bg-accent/60" />}
{covered === 0 && (
<span className="absolute inset-0 flex items-center justify-center font-mono text-[10px] uppercase tracking-[0.14em] text-text-tertiary">
Awaiting first checks
</span>
)}
</div>
);
}
File diff suppressed because one or more lines are too long