From 42358f57cb20331c7b297f14c8ecf092c1fcc8de Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 17 Sep 2026 08:33:29 +0000 Subject: [PATCH] feat(web): heartbeat monitor form, ping URL panel and token rotation --- web/app/(app)/monitors/[id]/page.tsx | 95 ++++++++++++--- web/app/(app)/monitors/new/page.tsx | 6 +- web/app/(app)/monitors/page.tsx | 8 +- web/components/monitors/HeartbeatUrlPanel.tsx | 35 ++++++ web/components/monitors/MonitorForm.tsx | 112 ++++++++++++------ web/components/monitors/MonitorVisuals.tsx | 1 + web/lib/api.ts | 28 ++++- 7 files changed, 227 insertions(+), 58 deletions(-) create mode 100644 web/components/monitors/HeartbeatUrlPanel.tsx diff --git a/web/app/(app)/monitors/[id]/page.tsx b/web/app/(app)/monitors/[id]/page.tsx index 78820cd..461f5bb 100644 --- a/web/app/(app)/monitors/[id]/page.tsx +++ b/web/app/(app)/monitors/[id]/page.tsx @@ -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(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(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 (
@@ -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 + {isHeartbeat && heartbeatToken &&
} +
@@ -496,6 +529,18 @@ export default function MonitorDetailPage() { } /> + setConfirmRotate(false)} + onConfirm={() => rotateToken()} + body={

The current URL stops working immediately.

} + /> +
@@ -525,7 +570,7 @@ export default function MonitorDetailPage() { />
@@ -554,20 +599,36 @@ export default function MonitorDetailPage() {
- -
- - - - {monitor.type === "http" && monitor.target.method && } - {monitor.type === "http" && monitor.target.expected_status && ( - - )} - {monitor.target.keyword && } - {monitor.type === "tls" && } - -
-
+ {isHeartbeat ? ( + +
+ + + + {monitor.state.started_at && ( + + )} +
+ +
+ ) : ( + +
+ + + + {monitor.type === "http" && monitor.target.method && } + {monitor.type === "http" && monitor.target.expected_status && ( + + )} + {monitor.target.keyword && } + {monitor.type === "tls" && } + +
+
+ )} {alertChannels.length === 0 ? ( diff --git a/web/app/(app)/monitors/new/page.tsx b/web/app/(app)/monitors/new/page.tsx index 26fe8c2..8a5c9cb 100644 --- a/web/app/(app)/monitors/new/page.tsx +++ b/web/app/(app)/monitors/new/page.tsx @@ -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}`); + } }, }); diff --git a/web/app/(app)/monitors/page.tsx b/web/app/(app)/monitors/page.tsx index ec5a760..f517c19 100644 --- a/web/app/(app)/monitors/page.tsx +++ b/web/app/(app)/monitors/page.tsx @@ -199,7 +199,13 @@ function MonitorRow({ monitor, rollups, incidents }: MonitorRowData) { {monitor.type} - {targetSummary(monitor)} + + {monitor.type === "heartbeat" + ? monitor.state.last_ping_at + ? `Last ping ${relativeTime(monitor.state.last_ping_at)}` + : "Waiting for first ping" + : targetSummary(monitor)} +
diff --git a/web/components/monitors/HeartbeatUrlPanel.tsx b/web/components/monitors/HeartbeatUrlPanel.tsx new file mode 100644 index 0000000..ea5c768 --- /dev/null +++ b/web/components/monitors/HeartbeatUrlPanel.tsx @@ -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 ( +
+

+ Copy this URL now. It is not shown again, rotate the token if you lose it. +

+
+ {url} + +
+
+{`# 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`}
+            
+
+ ); +} diff --git a/web/components/monitors/MonitorForm.tsx b/web/components/monitors/MonitorForm.tsx index c2ce11c..dd762b4 100644 --- a/web/components/monitors/MonitorForm.tsx +++ b/web/components/monitors/MonitorForm.tsx @@ -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 = { +const typeCopy: Partial> = { 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(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 (
-
+
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({ - {typeCopy[t].title} + {typeCopy[t]?.title} - {typeCopy[t].blurb} + {typeCopy[t]?.blurb} ))}
@@ -268,50 +279,75 @@ export function MonitorForm({ /> )} -
-
-
+ {type === "heartbeat" && (
- + setIntervalSec(Number(e.target.value))} - min={10} - /> - - - setRetries(Number(e.target.value))} + value={periodMin} + onChange={(e) => setPeriodMin(Number(e.target.value))} min={1} /> + + setGraceMin(Number(e.target.value))} + min={0} + /> +
+ )} +
- - - +
+ {type !== "heartbeat" && type !== "metric" && ( +
+
+ + setIntervalSec(Number(e.target.value))} + min={10} + /> + + + setRetries(Number(e.target.value))} + min={1} + /> + +
-

- Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "} - {retries === 1 ? "failure" : "consecutive failures"} - roughly {downAfter}. -

-
+ + + + +

+ Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "} + {retries === 1 ? "failure" : "consecutive failures"} - roughly {downAfter}. +

+
+ )}
0 ? `${channelIds.length} selected` : undefined}> {!channels || channels.length === 0 ? ( diff --git a/web/components/monitors/MonitorVisuals.tsx b/web/components/monitors/MonitorVisuals.tsx index 0f4d696..ead3e93 100644 --- a/web/components/monitors/MonitorVisuals.tsx +++ b/web/components/monitors/MonitorVisuals.tsx @@ -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 ?? ""}`; } diff --git a/web/lib/api.ts b/web/lib/api.ts index 3497fcf..100dfa2 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -45,9 +45,13 @@ export interface Server { tags?: Record; } -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; + 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(`/monitors/${monitorId}/samples?minutes=${minutes}`); }, + rotateHeartbeatToken(monitorId: string) { + return request<{ heartbeat_token: string }>(`/monitors/${monitorId}/rotate-token`, { method: "POST" }); + }, + listStatusPages(): Promise { return request("/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 {