feat: Updated monitors pages
This commit is contained in:
@@ -5,211 +5,341 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, Monitor, MonitorInput, MonitorType } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
import { formatDuration } from "@/components/monitors/MonitorVisuals";
|
||||
|
||||
/*
|
||||
* The form is grouped the way the detail page reads it back: what is checked,
|
||||
* how often, and who hears about it. The section names are the same words on
|
||||
* both screens — someone editing "Fails after" should recognise the number
|
||||
* they saw under "Fails after".
|
||||
*/
|
||||
|
||||
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 labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
|
||||
"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 }> = {
|
||||
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" },
|
||||
};
|
||||
|
||||
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-surface">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">{title}</h2>
|
||||
{hint && <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{hint}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, help, children }: { label: string; help?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</span>
|
||||
{children}
|
||||
{help && <span className="mt-1.5 block text-xs text-text-tertiary">{help}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({
|
||||
checked,
|
||||
onChange,
|
||||
title,
|
||||
detail,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
detail?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 transition-colors ${
|
||||
checked ? "border-accent/50 bg-accent/5" : "border-border bg-surface-2 hover:border-accent/30"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 accent-accent"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-text-primary">{title}</span>
|
||||
{detail && <span className="mt-0.5 block text-xs text-text-tertiary">{detail}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitorForm({
|
||||
initial,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
isPending,
|
||||
error,
|
||||
initial,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
isPending,
|
||||
error,
|
||||
}: {
|
||||
initial?: Monitor;
|
||||
submitLabel: string;
|
||||
onSubmit: (input: MonitorInput) => void;
|
||||
isPending: boolean;
|
||||
error?: Error | null;
|
||||
initial?: Monitor;
|
||||
submitLabel: string;
|
||||
onSubmit: (input: MonitorInput) => void;
|
||||
isPending: boolean;
|
||||
error?: Error | null;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [type, setType] = useState<MonitorType>(initial?.type ?? "http");
|
||||
const [url, setUrl] = useState(initial?.target.url ?? "");
|
||||
const [host, setHost] = useState(initial?.target.host ?? "");
|
||||
const [port, setPort] = useState<number>(initial?.target.port ?? 443);
|
||||
const [method, setMethod] = useState(initial?.target.method ?? "GET");
|
||||
const [expectedStatus, setExpectedStatus] = useState<number>(initial?.target.expected_status ?? 200);
|
||||
const [keyword, setKeyword] = useState(initial?.target.keyword ?? "");
|
||||
const [tlsWarnDays, setTlsWarnDays] = useState<number>(initial?.target.tls_warn_days ?? 14);
|
||||
const [insecure, setInsecure] = useState<boolean>(initial?.target.insecure ?? false);
|
||||
const [intervalSec, setIntervalSec] = useState<number>(initial?.interval_sec ?? 60);
|
||||
const [retries, setRetries] = useState<number>(initial?.retries ?? 1);
|
||||
const [runner, setRunner] = useState(initial?.runner ?? "server");
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [type, setType] = useState<MonitorType>(initial?.type ?? "http");
|
||||
const [url, setUrl] = useState(initial?.target.url ?? "");
|
||||
const [host, setHost] = useState(initial?.target.host ?? "");
|
||||
const [port, setPort] = useState<number>(initial?.target.port ?? 443);
|
||||
const [method, setMethod] = useState(initial?.target.method ?? "GET");
|
||||
const [expectedStatus, setExpectedStatus] = useState<number>(initial?.target.expected_status ?? 200);
|
||||
const [keyword, setKeyword] = useState(initial?.target.keyword ?? "");
|
||||
const [tlsWarnDays, setTlsWarnDays] = useState<number>(initial?.target.tls_warn_days ?? 14);
|
||||
const [insecure, setInsecure] = useState<boolean>(initial?.target.insecure ?? false);
|
||||
const [intervalSec, setIntervalSec] = useState<number>(initial?.interval_sec ?? 60);
|
||||
const [retries, setRetries] = useState<number>(initial?.retries ?? 1);
|
||||
const [runner, setRunner] = useState(initial?.runner ?? "server");
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
|
||||
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const target: MonitorInput["target"] = {};
|
||||
if (type === "http") {
|
||||
target.url = url;
|
||||
target.method = method;
|
||||
target.expected_status = expectedStatus;
|
||||
if (keyword) target.keyword = keyword;
|
||||
target.insecure = insecure;
|
||||
} else if (type === "tls") {
|
||||
target.host = host;
|
||||
target.port = port || 443;
|
||||
target.tls_warn_days = tlsWarnDays;
|
||||
} else if (type === "icmp") {
|
||||
target.host = host;
|
||||
} else {
|
||||
target.host = host;
|
||||
target.port = port;
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const target: MonitorInput["target"] = {};
|
||||
if (type === "http") {
|
||||
target.url = url;
|
||||
target.method = method;
|
||||
target.expected_status = expectedStatus;
|
||||
if (keyword) target.keyword = keyword;
|
||||
target.insecure = insecure;
|
||||
} else if (type === "tls") {
|
||||
target.host = host;
|
||||
target.port = port || 443;
|
||||
target.tls_warn_days = tlsWarnDays;
|
||||
} else if (type === "icmp") {
|
||||
target.host = host;
|
||||
} else {
|
||||
target.host = host;
|
||||
target.port = port;
|
||||
}
|
||||
onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
|
||||
}
|
||||
onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. API health" required />
|
||||
</div>
|
||||
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
|
||||
that is the number people are actually choosing between. */
|
||||
const downAfter = formatDuration(new Date(Date.now() - intervalSec * Math.max(retries, 1) * 1000).toISOString());
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Type</label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{(["http", "tcp", "icmp", "tls"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
type === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex max-w-3xl flex-col gap-5">
|
||||
<Section title="Check" hint={typeCopy[type].target}>
|
||||
<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 />
|
||||
</Field>
|
||||
|
||||
{type === "http" && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>URL</label>
|
||||
<input className={inputClass} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/health" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Method</label>
|
||||
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
<option>GET</option>
|
||||
<option>HEAD</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Expected status</label>
|
||||
<input type="number" className={inputClass} value={expectedStatus} onChange={(e) => setExpectedStatus(Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Keyword (optional, body must contain)</label>
|
||||
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="e.g. ok" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" checked={insecure} onChange={(e) => setInsecure(e.target.checked)} />
|
||||
Ignore TLS certificate errors (self-signed / expired)
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Kind</span>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{(Object.keys(typeCopy) as MonitorType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
aria-pressed={type === t}
|
||||
className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
|
||||
type === t
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`block font-mono text-[11px] uppercase tracking-[0.1em] ${type === t ? "text-accent" : "text-text-secondary"}`}
|
||||
>
|
||||
{typeCopy[t].title}
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t].blurb}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(type === "tcp" || type === "tls" || type === "icmp") && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Host</label>
|
||||
<input className={inputClass} value={host} onChange={(e) => setHost(e.target.value)} placeholder="example.com" required />
|
||||
</div>
|
||||
{type !== "icmp" && (
|
||||
<div>
|
||||
<label className={labelClass}>Port</label>
|
||||
<input type="number" className={inputClass} value={port} onChange={(e) => setPort(Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{type === "http" && (
|
||||
<>
|
||||
<Field label="URL">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/healthz"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Method">
|
||||
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
<option>GET</option>
|
||||
<option>HEAD</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Expected status">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={expectedStatus}
|
||||
onChange={(e) => setExpectedStatus(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Body must contain" help="Optional. The check fails if the response body is missing this text.">
|
||||
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="ok" />
|
||||
</Field>
|
||||
<Check
|
||||
checked={insecure}
|
||||
onChange={setInsecure}
|
||||
title="Accept any certificate"
|
||||
detail="Use for self-signed or expired certificates. The check stops reporting TLS problems."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "tls" && (
|
||||
<div>
|
||||
<label className={labelClass}>Warn days before expiry</label>
|
||||
<input type="number" className={inputClass} value={tlsWarnDays} onChange={(e) => setTlsWarnDays(Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
{(type === "tcp" || type === "tls" || type === "icmp") && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Host">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
{type !== "icmp" && (
|
||||
<Field label="Port">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={port}
|
||||
onChange={(e) => setPort(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Interval (seconds)</label>
|
||||
<input type="number" className={inputClass} value={intervalSec} onChange={(e) => setIntervalSec(Number(e.target.value))} min={10} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Retries before down</label>
|
||||
<input type="number" className={inputClass} value={retries} onChange={(e) => setRetries(Number(e.target.value))} min={1} />
|
||||
</div>
|
||||
</div>
|
||||
{type === "tls" && (
|
||||
<Field label="Warn this many days before expiry">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={tlsWarnDays}
|
||||
onChange={(e) => setTlsWarnDays(Number(e.target.value))}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Runner</label>
|
||||
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
|
||||
<option value="server">Server (central)</option>
|
||||
{servers?.map((s) => (
|
||||
<option key={s.server_id} value={s.server_id}>
|
||||
Agent · {s.hostname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-text-tertiary">Agent-run monitors require the agent monitor scheduler (P2).</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notification channels</label>
|
||||
{!channels || channels.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary">
|
||||
No channels yet.{" "}
|
||||
<Link href="/settings/notifications" className="text-accent hover:underline">
|
||||
Add one
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{channels.map((ch) => (
|
||||
<label key={ch.channel_id} className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channelIds.includes(ch.channel_id)}
|
||||
onChange={(e) =>
|
||||
setChannelIds((prev) => (e.target.checked ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
|
||||
}
|
||||
<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 ? (
|
||||
<p className="text-sm text-text-secondary">
|
||||
No channels exist yet, so nobody will be told when this check fails.{" "}
|
||||
<Link href="/settings/notifications" className="text-accent hover:underline">
|
||||
Add a channel
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{channels.map((ch) => (
|
||||
<Check
|
||||
key={ch.channel_id}
|
||||
checked={channelIds.includes(ch.channel_id)}
|
||||
onChange={(v) =>
|
||||
setChannelIds((prev) => (v ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
|
||||
}
|
||||
title={ch.name}
|
||||
detail={
|
||||
<span className="font-mono uppercase tracking-[0.1em]">
|
||||
{ch.type}
|
||||
{!ch.enabled && " · disabled, sends nothing"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Check
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
title="Start checking straight away"
|
||||
detail="Turn this off to save the monitor without running it. You can resume it any time."
|
||||
/>
|
||||
{ch.name} <span className="text-text-tertiary">({ch.type})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enabled
|
||||
</label>
|
||||
{error && (
|
||||
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error.message}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-danger">{error.message}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
<Link href="/monitors">
|
||||
<Button type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
<Link href="/monitors">
|
||||
<Button type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, MonitorStatus, Rollup } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* Shared vocabulary for the monitors screens.
|
||||
*
|
||||
* State is never carried by colour alone: every chip pairs a distinct shape
|
||||
* with a text label, and every row carries a stripe on its left edge. Colour
|
||||
* is the third signal, not the only one.
|
||||
*/
|
||||
|
||||
export type DisplayStatus = MonitorStatus | "paused";
|
||||
|
||||
export function displayStatus(m: Monitor): DisplayStatus {
|
||||
return m.enabled ? m.state.status : "paused";
|
||||
}
|
||||
|
||||
export const statusLabel: Record<DisplayStatus, string> = {
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
pending: "Pending",
|
||||
paused: "Paused",
|
||||
};
|
||||
|
||||
/** The stripe colour for a row or panel edge. */
|
||||
export const statusStripe: Record<DisplayStatus, string> = {
|
||||
up: "bg-success",
|
||||
down: "bg-danger",
|
||||
pending: "bg-warning",
|
||||
paused: "bg-text-tertiary",
|
||||
};
|
||||
|
||||
const chipTone: Record<DisplayStatus, string> = {
|
||||
up: "text-success border-success/35 bg-success/10",
|
||||
down: "text-danger border-danger/40 bg-danger/10",
|
||||
pending: "text-warning border-warning/35 bg-warning/10",
|
||||
paused: "text-text-tertiary border-border",
|
||||
};
|
||||
|
||||
/** Shape is the primary signal — round for up, triangle for down, and so on. */
|
||||
function StatusMark({ status }: { status: DisplayStatus }) {
|
||||
if (status === "up") return <span className="block h-[7px] w-[7px] rounded-full bg-success" />;
|
||||
if (status === "down") return <span className="block h-0 w-0 border-x-[4px] border-b-[7px] border-x-transparent border-b-danger" />;
|
||||
if (status === "pending") return <span className="block h-[6px] w-[6px] rotate-45 bg-warning" />;
|
||||
return <span className="block h-[2px] w-[7px] bg-text-tertiary" />;
|
||||
}
|
||||
|
||||
export function StatusChip({ status, className = "" }: { status: DisplayStatus; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-[11px] uppercase tracking-[0.08em] ${chipTone[status]} ${className}`}
|
||||
>
|
||||
<StatusMark status={status} />
|
||||
{statusLabel[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- numbers */
|
||||
|
||||
export function uptimePct(rollups: Rollup[]): number | null {
|
||||
const checks = rollups.reduce((a, r) => a + r.checks, 0);
|
||||
if (checks === 0) return null;
|
||||
return (rollups.reduce((a, r) => a + r.up_count, 0) / checks) * 100;
|
||||
}
|
||||
|
||||
export function avgLatency(rollups: Rollup[]): number | null {
|
||||
const checks = rollups.reduce((a, r) => a + r.checks, 0);
|
||||
if (checks === 0) return null;
|
||||
return rollups.reduce((a, r) => a + r.sum_latency, 0) / checks;
|
||||
}
|
||||
|
||||
export function formatPct(pct: number | null): string {
|
||||
if (pct === null) return "—";
|
||||
return pct >= 99.995 ? "100" : pct.toFixed(2);
|
||||
}
|
||||
|
||||
export function formatMs(ms: number | null): string {
|
||||
if (ms === null) return "—";
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
|
||||
return `${Math.round(ms)} ms`;
|
||||
}
|
||||
|
||||
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 ?? "";
|
||||
return `${m.target.host ?? ""}:${m.target.port ?? ""}`;
|
||||
}
|
||||
|
||||
export function relativeTime(iso?: string): string {
|
||||
if (!iso) return "never";
|
||||
const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 60) return `${Math.max(secs, 0)}s ago`;
|
||||
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
|
||||
if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
|
||||
return `${Math.round(secs / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export function formatDuration(fromIso: string, toIso?: string): string {
|
||||
const mins = Math.max(Math.round((new Date(toIso ?? Date.now()).getTime() - new Date(fromIso).getTime()) / 60000), 0);
|
||||
if (mins < 60) return `${mins} min`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ${mins % 60}m`;
|
||||
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- tape */
|
||||
|
||||
export interface Slot {
|
||||
/** Start of the hour this slot covers. */
|
||||
at: Date;
|
||||
/** Percentage of checks that passed, or null when no check ran. */
|
||||
pct: number | null;
|
||||
checks: number;
|
||||
latency: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket rollups into the last `hours` whole hours, ending with the hour in
|
||||
* progress. Rollups are sparse — an hour the scheduler never reached has no
|
||||
* document at all — so the slots are built from the clock and filled in, not
|
||||
* read off the array. A missing hour has to read as a gap rather than
|
||||
* silently shifting the rest of the tape sideways.
|
||||
*/
|
||||
export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
|
||||
const byHour = new Map<number, Rollup>();
|
||||
for (const r of rollups) {
|
||||
const t = new Date(r.period_start);
|
||||
t.setMinutes(0, 0, 0);
|
||||
byHour.set(t.getTime(), r);
|
||||
}
|
||||
|
||||
const cursor = new Date();
|
||||
cursor.setMinutes(0, 0, 0);
|
||||
|
||||
const slots: Slot[] = [];
|
||||
for (let i = hours - 1; i >= 0; i--) {
|
||||
const at = new Date(cursor.getTime() - i * 3600_000);
|
||||
const r = byHour.get(at.getTime());
|
||||
slots.push({
|
||||
at,
|
||||
checks: r?.checks ?? 0,
|
||||
pct: r && r.checks > 0 ? (r.up_count / r.checks) * 100 : null,
|
||||
latency: r && r.checks > 0 ? r.sum_latency / r.checks : null,
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
function slotColor(s: Slot): string {
|
||||
if (s.pct === null) return "bg-border-soft";
|
||||
if (s.pct >= 99.5) return "bg-success";
|
||||
if (s.pct >= 80) return "bg-warning";
|
||||
return "bg-danger";
|
||||
}
|
||||
|
||||
function slotHeight(s: Slot): number {
|
||||
if (s.pct === null) return 26;
|
||||
if (s.pct < 80) return 100;
|
||||
return 55 + (s.pct - 80) * 2.2;
|
||||
}
|
||||
|
||||
function slotTitle(s: Slot): string {
|
||||
const when = s.at.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" });
|
||||
if (s.pct === null) return `${when} · no checks ran`;
|
||||
return `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tape: 48 hours of history as one strip. This is the spine of both
|
||||
* screens — a row shows what has been happening, not only what is true this
|
||||
* second.
|
||||
*/
|
||||
export function Tape({ slots, height = "h-9", live = true }: { slots: Slot[]; height?: string; live?: boolean }) {
|
||||
const covered = slots.filter((s) => s.checks > 0).length;
|
||||
|
||||
return (
|
||||
<div className={`relative flex ${height} items-end gap-px rounded-sm bg-well p-[3px]`}>
|
||||
{slots.map((s) => (
|
||||
<div key={s.at.getTime()} className="flex h-full flex-1 items-end" title={slotTitle(s)}>
|
||||
<div className={`w-full rounded-[1px] ${slotColor(s)}`} style={{ height: `${slotHeight(s)}%` }} />
|
||||
</div>
|
||||
))}
|
||||
{live && <span className="absolute inset-y-0 right-0 w-px bg-accent/60" />}
|
||||
{covered === 0 && (
|
||||
<span className="absolute inset-0 flex items-center justify-center font-mono text-[10px] uppercase tracking-[0.14em] text-text-tertiary">
|
||||
Awaiting first checks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user