feat(web): heartbeat monitor form, ping URL panel and token rotation

This commit is contained in:
2026-09-17 08:33:29 +00:00
parent 17ed0192c1
commit 42358f57cb
7 changed files with 227 additions and 58 deletions
+78 -17
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { api, Incident, Rollup } from "@/lib/api";
import { Button, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { HeartbeatUrlPanel } from "@/components/monitors/HeartbeatUrlPanel";
import {
Slot,
StatusChip,
@@ -316,12 +317,28 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
export default function MonitorDetailPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const monitorId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmRotate, setConfirmRotate] = useState(false);
const [range, setRange] = useState<Range>(RANGES[0]);
const toast = useToast();
/* The token is only ever handed back once, on the create response, riding
in the query string for this one navigation. Keep it in state and strip
the query immediately so a reload, a bookmark or browser history never
holds it. */
const [heartbeatToken, setHeartbeatToken] = useState<string | null>(null);
useEffect(() => {
const t = searchParams.get("token");
if (t) {
setHeartbeatToken(t);
router.replace(`/monitors/${monitorId}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
queryFn: () => api.getMonitor(monitorId),
@@ -387,6 +404,19 @@ export default function MonitorDetailPage() {
onError: toast.error,
});
const {
mutate: rotateToken,
isPending: isRotating,
error: rotateError,
} = useMutation({
mutationFn: () => api.rotateHeartbeatToken(monitorId),
onSuccess: (res) => {
setHeartbeatToken(res.heartbeat_token);
setConfirmRotate(false);
toast.success("Token rotated. The old URL stops working immediately.");
},
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
@@ -430,6 +460,7 @@ export default function MonitorDetailPage() {
? Math.round((new Date(monitor.state.cert_expiry_at).getTime() - Date.now()) / 86400_000)
: null;
const isHeartbeat = monitor.type === "heartbeat";
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;
@@ -441,6 +472,8 @@ export default function MonitorDetailPage() {
Monitors
</Link>
{isHeartbeat && heartbeatToken && <div className="mb-5"><HeartbeatUrlPanel token={heartbeatToken} /></div>}
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
@@ -496,6 +529,18 @@ export default function MonitorDetailPage() {
}
/>
<ConfirmDialog
open={confirmRotate}
title="Rotate ping token"
confirmLabel="Rotate"
destructive={false}
loading={isRotating}
error={rotateError ? friendlyMessage(rotateError) : null}
onClose={() => setConfirmRotate(false)}
onConfirm={() => rotateToken()}
body={<p>The current URL stops working immediately.</p>}
/>
<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">
@@ -525,7 +570,7 @@ export default function MonitorDetailPage() {
/>
<Figure label="Uptime 30d" value={formatPct(pct30d)} unit={pct30d !== null ? "%" : undefined} />
<Figure
label="Response now"
label={isHeartbeat ? "Duration" : "Response now"}
value={status === "down" ? "-" : formatMs(latency)}
tone={status === "down" ? "text-danger" : ""}
/>
@@ -554,20 +599,36 @@ export default function MonitorDetailPage() {
</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>
{isHeartbeat ? (
<Panel title="Heartbeat">
<dl className="text-[13px]">
<Row label="Last ping" value={relativeTime(monitor.state.last_ping_at)} />
<Row label="Expected every" value={`${Math.round((monitor.target.period_sec ?? 0) / 60)} min`} />
<Row label="Grace" value={`${Math.round((monitor.target.grace_sec ?? 0) / 60)} min`} />
{monitor.state.started_at && (
<Row label="Running since" value={relativeTime(monitor.state.started_at)} />
)}
</dl>
<Button variant="secondary" size="sm" className="mt-4" onClick={() => setConfirmRotate(true)}>
Rotate token
</Button>
</Panel>
) : (
<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 ? (
+5 -1
View File
@@ -24,7 +24,11 @@ export default function NewMonitorPage() {
// survive the route change, which is the one thing an inline
// banner on the form cannot do. The error stays on the form.
toast.success(`Created ${m.name}. First check runs within its interval.`);
router.push(`/monitors/${m.monitor_id}`);
if (m.heartbeat_token) {
router.push(`/monitors/${m.monitor_id}?token=${encodeURIComponent(m.heartbeat_token)}`);
} else {
router.push(`/monitors/${m.monitor_id}`);
}
},
});
+7 -1
View File
@@ -199,7 +199,13 @@ function MonitorRow({ monitor, rollups, incidents }: MonitorRowData) {
{monitor.type}
</span>
</span>
<span className="mt-1 block truncate font-mono text-[11.5px] text-text-tertiary">{targetSummary(monitor)}</span>
<span className="mt-1 block truncate font-mono text-[11.5px] text-text-tertiary">
{monitor.type === "heartbeat"
? monitor.state.last_ping_at
? `Last ping ${relativeTime(monitor.state.last_ping_at)}`
: "Waiting for first ping"
: targetSummary(monitor)}
</span>
</div>
<Tape slots={slots} live={monitor.enabled} />