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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user