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>
);
}