feat(web): heartbeat monitor form, ping URL panel and token rotation
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user