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} />
@@ -0,0 +1,35 @@
"use client";
import { heartbeatUrl } from "@/lib/api";
import { Button, useToast } from "@/components/ui";
/** Shown once, straight after create or rotate: the token is not retrievable later. */
export function HeartbeatUrlPanel({ token }: { token: string }) {
const toast = useToast();
const url = heartbeatUrl(token);
const copy = async () => {
await navigator.clipboard.writeText(url);
toast.success("Ping URL copied");
};
return (
<div className="space-y-3 rounded border border-accent/40 bg-well p-4">
<p className="text-sm text-text-primary">
Copy this URL now. It is not shown again, rotate the token if you lose it.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface px-2 py-1.5 font-mono text-xs">{url}</code>
<Button size="sm" onClick={copy}>Copy</Button>
</div>
<pre className="overflow-x-auto rounded bg-surface p-3 font-mono text-[11px] leading-relaxed text-text-secondary">
{`# success
curl -fsS -m 10 --retry 3 ${url}
# mark start, to measure duration
curl -fsS -m 10 ${url}/start
# report failure with output
your-job 2>&1 | tail -c 1024 | curl -fsS -m 10 --data-binary @- ${url}/fail
# or keep the token out of URLs (and your proxy logs)
curl -fsS -m 10 -X POST -H "X-Vantage-Token: ${token}" ${window.location.origin}/public/hb`}
</pre>
</div>
);
}
+74 -38
View File
@@ -17,11 +17,12 @@ import { formatDuration } from "@/components/monitors/MonitorVisuals";
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 typeCopy: Record<MonitorType, { title: string; blurb: string; target: string }> = {
const typeCopy: Partial<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" },
heartbeat: { title: "Heartbeat", blurb: "Your job calls a URL; alert when it stops.", target: "Ping URL" },
};
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
@@ -106,6 +107,8 @@ export function MonitorForm({
const [runner, setRunner] = useState(initial?.runner ?? "server");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
const [periodMin, setPeriodMin] = useState(Math.round((initial?.target.period_sec ?? 3600) / 60));
const [graceMin, setGraceMin] = useState(Math.round((initial?.target.grace_sec ?? 300) / 60));
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
/* The group is free text, so the existing groups are offered as suggestions
@@ -130,6 +133,9 @@ export function MonitorForm({
target.tls_warn_days = tlsWarnDays;
} else if (type === "icmp") {
target.host = host;
} else if (type === "heartbeat") {
target.period_sec = periodMin * 60;
target.grace_sec = graceMin * 60;
} else {
target.host = host;
target.port = port;
@@ -137,6 +143,10 @@ export function MonitorForm({
onSubmit({ name, group: group.trim(), type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
}
/* The server rejects a type change to or from heartbeat or metric, so an
edit of one of those locks the picker rather than letting the operator
hit a save error. */
const typePickerDisabled = !!initial && (initial.type === "heartbeat" || initial.type === "metric");
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
@@ -146,7 +156,7 @@ export function MonitorForm({
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,400px)]">
<Section title="Check" hint={typeCopy[type].target}>
<Section title="Check" hint={typeCopy[type]?.target}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<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 />
@@ -177,7 +187,8 @@ export function MonitorForm({
type="button"
onClick={() => setType(t)}
aria-pressed={type === t}
className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
disabled={typePickerDisabled}
className={`rounded-lg border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
type === t
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40"
@@ -186,9 +197,9 @@ export function MonitorForm({
<span
className={`block font-mono text-[11px] uppercase tracking-[0.1em] ${type === t ? "text-accent" : "text-text-secondary"}`}
>
{typeCopy[t].title}
{typeCopy[t]?.title}
</span>
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t].blurb}</span>
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t]?.blurb}</span>
</button>
))}
</div>
@@ -268,50 +279,75 @@ export function MonitorForm({
/>
</Field>
)}
</Section>
<div className="flex flex-col gap-5">
<Section title="Schedule">
{type === "heartbeat" && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Run every" help="Seconds between checks. Minimum 10.">
<Field label="Expected every (minutes)">
<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))}
value={periodMin}
onChange={(e) => setPeriodMin(Number(e.target.value))}
min={1}
/>
</Field>
<Field label="Grace (minutes)">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={graceMin}
onChange={(e) => setGraceMin(Number(e.target.value))}
min={0}
/>
</Field>
</div>
)}
</Section>
<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>
<div className="flex flex-col gap-5">
{type !== "heartbeat" && type !== "metric" && (
<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>
<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>
<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 ? (
@@ -86,6 +86,7 @@ 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 ?? "";
if (m.type === "heartbeat") return "Ping URL";
return `${m.target.host ?? ""}:${m.target.port ?? ""}`;
}
+27 -1
View File
@@ -45,9 +45,13 @@ export interface Server {
tags?: Record<string, string>;
}
export type MonitorType = "http" | "tcp" | "icmp" | "tls";
export type MonitorType = "http" | "tcp" | "icmp" | "tls" | "heartbeat" | "metric";
export type MonitorStatus = "up" | "down" | "pending";
export type MetricKind =
| "disk_pct" | "disk_free_gb" | "mem_pct" | "load_per_core"
| "unit_failed" | "container_unhealthy" | "reboot_pending_days" | "agent_offline_min";
export interface MonitorTarget {
url?: string;
host?: string;
@@ -57,6 +61,12 @@ export interface MonitorTarget {
keyword?: string;
tls_warn_days?: number;
insecure?: boolean;
period_sec?: number;
grace_sec?: number;
selector?: Record<string, string>;
metric?: MetricKind;
threshold?: number;
mount?: string;
}
export interface MonitorState {
@@ -66,6 +76,8 @@ export interface MonitorState {
message?: string;
cert_expiry_at?: string;
fails: number;
last_ping_at?: string;
started_at?: string;
}
export interface Monitor {
@@ -82,6 +94,9 @@ export interface Monitor {
channel_ids?: string[];
state: MonitorState;
created_at: string;
for_sec?: number;
/** Plaintext heartbeat ping token. Only ever present on the create response. */
heartbeat_token?: string;
}
export interface MonitorInput {
@@ -94,6 +109,7 @@ export interface MonitorInput {
retries: number;
enabled: boolean;
channel_ids?: string[];
for_sec?: number;
}
export interface Incident {
@@ -102,6 +118,7 @@ export interface Incident {
started_at: string;
resolved_at?: string;
cause?: string;
server_id?: string;
}
/** One check result. Kept for 48 hours, which is what the sub-hour views read. */
@@ -1026,6 +1043,10 @@ export const api = {
return request<MonitorSample[]>(`/monitors/${monitorId}/samples?minutes=${minutes}`);
},
rotateHeartbeatToken(monitorId: string) {
return request<{ heartbeat_token: string }>(`/monitors/${monitorId}/rotate-token`, { method: "POST" });
},
listStatusPages(): Promise<StatusPage[]> {
return request<StatusPage[]>("/status-pages");
},
@@ -1441,6 +1462,11 @@ export const api = {
},
};
/** The ping URL a job calls. Same origin as the app: /public is routed to the server everywhere. */
export function heartbeatUrl(token: string): string {
return `${window.location.origin}/public/hb/${token}`;
}
export type LicenseState = "valid" | "expired" | "invalid";
export interface LicenseInfo {