feat: Updated monitors pages
This commit is contained in:
@@ -4,55 +4,65 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, MonitorInput } from "@/lib/api";
|
||||
import { Card } from "@/components/ui";
|
||||
import { MonitorForm } from "@/components/monitors/MonitorForm";
|
||||
|
||||
export default function EditMonitorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const monitorId = params.id as string;
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const monitorId = params.id as string;
|
||||
|
||||
const { data: monitor, isLoading } = useQuery({
|
||||
queryKey: ["monitors", monitorId],
|
||||
queryFn: () => api.getMonitor(monitorId),
|
||||
});
|
||||
const { data: monitor, isLoading } = useQuery({
|
||||
queryKey: ["monitors", monitorId],
|
||||
queryFn: () => api.getMonitor(monitorId),
|
||||
});
|
||||
|
||||
const { mutate: update, isPending, error } = useMutation({
|
||||
mutationFn: (input: MonitorInput) => api.updateMonitor(monitorId, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
|
||||
router.push(`/monitors/${monitorId}`);
|
||||
},
|
||||
});
|
||||
const {
|
||||
mutate: update,
|
||||
isPending,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutationFn: (input: MonitorInput) => api.updateMonitor(monitorId, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
|
||||
router.push(`/monitors/${monitorId}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!monitor) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
|
||||
That monitor no longer exists.{" "}
|
||||
<Link href="/monitors" className="underline">
|
||||
Back to monitors
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<Link href={`/monitors/${monitorId}`} className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
|
||||
← {monitor.name}
|
||||
</Link>
|
||||
<div className="mb-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Editing</p>
|
||||
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">{monitor.name}</h1>
|
||||
</div>
|
||||
|
||||
<MonitorForm initial={monitor} submitLabel="Save changes" onSubmit={update} isPending={isPending} error={error as Error | null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!monitor) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<Link href={`/monitors/${monitorId}`} className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← {monitor.name}
|
||||
</Link>
|
||||
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">Edit Monitor</h1>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<MonitorForm initial={monitor} submitLabel="Save Changes" onSubmit={update} isPending={isPending} error={error as Error | null} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,36 +4,174 @@ import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, Monitor, MonitorStatus, Rollup } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardHeader, CardTitle, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { api, Incident, Rollup } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
Slot,
|
||||
StatusChip,
|
||||
avgLatency,
|
||||
buildSlots,
|
||||
displayStatus,
|
||||
formatDuration,
|
||||
formatMs,
|
||||
formatPct,
|
||||
relativeTime,
|
||||
statusStripe,
|
||||
targetSummary,
|
||||
uptimePct,
|
||||
} from "@/components/monitors/MonitorVisuals";
|
||||
|
||||
function statusVariant(status: MonitorStatus) {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return "success";
|
||||
case "down":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
/*
|
||||
* Hourly uptime and response time share one time axis, because the question
|
||||
* people actually arrive with is whether the slow hour and the failing hour
|
||||
* are the same hour. The incidents below are the same story told in words.
|
||||
*/
|
||||
|
||||
const CHART_W = 480;
|
||||
const CHART_H = 158;
|
||||
|
||||
function niceCeiling(ms: number): number {
|
||||
if (ms <= 0) return 100;
|
||||
const steps = [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
|
||||
return steps.find((s) => s >= ms) ?? Math.ceil(ms / 10000) * 10000;
|
||||
}
|
||||
|
||||
function uptimePct(rollups: Rollup[]): number {
|
||||
const checks = rollups.reduce((a, r) => a + r.checks, 0);
|
||||
const up = rollups.reduce((a, r) => a + r.up_count, 0);
|
||||
return checks > 0 ? (up / checks) * 100 : 0;
|
||||
}
|
||||
function History({ slots }: { slots: Slot[] }) {
|
||||
const latencies = slots.map((s) => s.latency).filter((v): v is number => v !== null);
|
||||
const scale = niceCeiling(Math.max(...latencies, 0) * 1.15);
|
||||
|
||||
/*
|
||||
* The trace breaks over hours with no checks rather than drawing a
|
||||
* straight line across them — a line implies data that was never
|
||||
* collected.
|
||||
*/
|
||||
const segments: string[] = [];
|
||||
let open: string[] = [];
|
||||
slots.forEach((s, i) => {
|
||||
if (s.latency === null) {
|
||||
if (open.length > 1) segments.push(open.join(" "));
|
||||
open = [];
|
||||
return;
|
||||
}
|
||||
const x = (i / Math.max(slots.length - 1, 1)) * CHART_W;
|
||||
const y = CHART_H - Math.min(s.latency / scale, 1) * CHART_H;
|
||||
open.push(`${open.length === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`);
|
||||
});
|
||||
if (open.length > 1) segments.push(open.join(" "));
|
||||
|
||||
const firstAt = slots[0]?.at;
|
||||
const midAt = slots[Math.floor(slots.length / 2)]?.at;
|
||||
const tick = (d?: Date) => (d ? d.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" }) : "");
|
||||
|
||||
function Heartbeat({ rollups }: { rollups: Rollup[] }) {
|
||||
const recent = rollups.slice(-48);
|
||||
return (
|
||||
<div className="flex items-end gap-0.5">
|
||||
{recent.map((r) => {
|
||||
const pct = r.checks > 0 ? (r.up_count / r.checks) * 100 : 0;
|
||||
const color = r.checks === 0 ? "bg-surface-2" : pct >= 99 ? "bg-success" : pct >= 80 ? "bg-warning" : "bg-danger";
|
||||
return <div key={r.period_start} className={`h-8 w-1.5 rounded-sm ${color}`} title={`${new Date(r.period_start).toLocaleString()} ${pct.toFixed(0)}% up`} />;
|
||||
})}
|
||||
{recent.length === 0 && <span className="text-xs text-text-secondary">No history yet.</span>}
|
||||
<div>
|
||||
<div className="relative h-[190px] overflow-hidden rounded-sm bg-well px-2.5 pb-6 pt-2.5">
|
||||
{[1, 0.5].map((f) => (
|
||||
<div key={f} className="pointer-events-none absolute left-2.5 right-2.5 border-t border-dashed border-border-soft" style={{ top: `${10 + (1 - f) * CHART_H}px` }}>
|
||||
<span className="absolute right-0 -top-[11px] bg-well pl-1.5 font-mono text-[9px] text-text-tertiary">{formatMs(scale * f)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="absolute inset-x-2.5 bottom-6 top-2.5 flex items-end gap-0.5">
|
||||
{slots.map((s) => (
|
||||
<div
|
||||
key={s.at.getTime()}
|
||||
className="h-full flex-1"
|
||||
title={s.pct === null ? "no checks ran" : `${s.pct.toFixed(1)}% up`}
|
||||
style={{ display: "flex", alignItems: "flex-end" }}
|
||||
>
|
||||
<div
|
||||
className={`w-full rounded-[1px] ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
|
||||
style={{ height: s.pct === null ? "18%" : `${Math.max(s.pct, 12)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="absolute inset-x-2.5 bottom-6 top-2.5 h-[calc(100%-2rem)] w-[calc(100%-1.25rem)]"
|
||||
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden
|
||||
>
|
||||
{segments.map((d, i) => (
|
||||
<path key={i} d={d} fill="none" stroke="rgb(var(--accent-rgb))" strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-x-2.5 bottom-1 flex justify-between font-mono text-[10px] text-text-tertiary">
|
||||
<span>{tick(firstAt)}</span>
|
||||
<span>{tick(midAt)}</span>
|
||||
<span>now</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-4 text-[11.5px] text-text-secondary">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="block h-2.5 w-2.5 rounded-sm bg-success/60" /> Hourly uptime
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="block h-0.5 w-3.5 bg-accent" /> Response time
|
||||
</span>
|
||||
<span className="text-text-tertiary">Gaps mean no checks ran</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({ label, value, unit, tone = "" }: { label: string; value: string; unit?: string; tone?: string }) {
|
||||
return (
|
||||
<div className="border-l border-border-soft px-5 py-3.5 first:border-l-0">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{label}</p>
|
||||
<p className={`mt-1 font-mono text-xl font-semibold tabular-nums tracking-tight ${tone || "text-text-primary"}`}>
|
||||
{value}
|
||||
{unit && <span className="text-xs font-normal text-text-tertiary">{unit}</span>}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentRow({ incident }: { incident: Incident }) {
|
||||
const open = !incident.resolved_at;
|
||||
const started = new Date(incident.started_at);
|
||||
return (
|
||||
<div className="grid grid-cols-[14px_minmax(0,1fr)] items-start gap-3 border-t border-border-soft px-5 py-3 first:border-t-0">
|
||||
<span className={`mt-1.5 block h-2 w-2 rounded-full ${open ? "bg-danger ring-4 ring-danger/20" : "bg-text-tertiary"}`} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13.5px] text-text-primary">{incident.cause || "Check failed"}</p>
|
||||
<p className="mt-1 flex flex-wrap gap-2 font-mono text-[11px] text-text-tertiary">
|
||||
<span>
|
||||
{started.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}
|
||||
{incident.resolved_at && ` → ${new Date(incident.resolved_at).toLocaleString(undefined, { hour: "2-digit", minute: "2-digit" })}`}
|
||||
</span>
|
||||
<span className="text-border">·</span>
|
||||
<span className="text-text-secondary">
|
||||
{open ? "Ongoing, " : ""}
|
||||
{formatDuration(incident.started_at, incident.resolved_at)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, aside, children, padded = true }: { title: string; aside?: string; children: React.ReactNode; padded?: boolean }) {
|
||||
return (
|
||||
<div 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>
|
||||
{aside && <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{aside}</span>}
|
||||
</div>
|
||||
<div className={padded ? "p-5" : ""}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3 border-t border-border-soft py-2.5 first:border-t-0 first:pt-0">
|
||||
<dt className="text-text-tertiary">{label}</dt>
|
||||
<dd className="break-all text-right font-mono text-text-primary">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +201,18 @@ export default function MonitorDetailPage() {
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["channels"],
|
||||
queryFn: () => api.listChannels(),
|
||||
enabled: (monitor?.channel_ids?.length ?? 0) > 0,
|
||||
});
|
||||
|
||||
const { data: servers } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: () => api.listServers(),
|
||||
enabled: !!monitor && monitor.runner !== "server",
|
||||
});
|
||||
|
||||
const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteMonitor(monitorId),
|
||||
onSuccess: () => {
|
||||
@@ -71,7 +221,7 @@ export default function MonitorDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: toggleEnabled } = useMutation({
|
||||
const { mutate: toggleEnabled, isPending: isToggling } = useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }),
|
||||
});
|
||||
@@ -87,35 +237,57 @@ export default function MonitorDetailPage() {
|
||||
if (!monitor) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
|
||||
That monitor no longer exists.{" "}
|
||||
<Link href="/monitors" className="underline">
|
||||
Back to monitors
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const all = rollups ?? [];
|
||||
const last24 = all.slice(-24);
|
||||
const all: Rollup[] = rollups ?? [];
|
||||
const slots = buildSlots(all);
|
||||
const status = displayStatus(monitor);
|
||||
const pct24 = uptimePct(all.slice(-24));
|
||||
const pct30d = uptimePct(all);
|
||||
const latency = monitor.state.latency_ms > 0 ? monitor.state.latency_ms : avgLatency(all.slice(-1));
|
||||
|
||||
const certDays = monitor.state.cert_expiry_at
|
||||
? Math.round((new Date(monitor.state.cert_expiry_at).getTime() - Date.now()) / 86400_000)
|
||||
: null;
|
||||
|
||||
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;
|
||||
const alertChannels = (monitor.channel_ids ?? []).map((id) => channels?.find((c) => c.channel_id === id));
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<Link href="/monitors" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
|
||||
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{monitor.name}</h1>
|
||||
<Badge variant={statusVariant(monitor.state.status)}>{monitor.state.status}</Badge>
|
||||
<Badge variant="neutral">{monitor.type}</Badge>
|
||||
{!monitor.enabled && <Badge variant="warning">disabled</Badge>}
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-primary">{monitor.name}</h1>
|
||||
<StatusChip status={status} />
|
||||
<span className="rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-secondary">
|
||||
{monitor.type}
|
||||
</span>
|
||||
</div>
|
||||
{monitor.state.message && <p className="mt-1 text-sm text-text-secondary">{monitor.state.message}</p>}
|
||||
<p className="mt-1.5 break-all font-mono text-xs text-text-tertiary">{targetSummary(monitor)}</p>
|
||||
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={`/monitors/${monitorId}/edit`}>
|
||||
<Button variant="secondary">Edit</Button>
|
||||
</Link>
|
||||
<Button variant="secondary" onClick={() => toggleEnabled(!monitor.enabled)}>
|
||||
{monitor.enabled ? "Disable" : "Enable"}
|
||||
<Button variant="secondary" loading={isToggling} onClick={() => toggleEnabled(!monitor.enabled)}>
|
||||
{monitor.enabled ? "Pause checks" : "Resume checks"}
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
@@ -123,108 +295,115 @@ export default function MonitorDetailPage() {
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<span className="text-sm text-danger">Delete this monitor and its history?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={() => deleteMonitor()}>
|
||||
Confirm
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
Keep
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Uptime (24h)</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(last24).toFixed(1)}%</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Uptime (30d)</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(all).toFixed(1)}%</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Latency</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{monitor.state.latency_ms}ms</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Cert expiry</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-primary">{monitor.state.cert_expiry_at ? new Date(monitor.state.cert_expiry_at).toLocaleDateString() : "n/a"}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Heartbeat (last 48h)</CardTitle>
|
||||
</CardHeader>
|
||||
<Heartbeat rollups={all} />
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card padding={false}>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Incidents</h2>
|
||||
<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">
|
||||
<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">Last 48 hours</h2>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">1 hour per bar</span>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<History slots={slots} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-t border-border-soft sm:grid-cols-4">
|
||||
<Figure
|
||||
label="Uptime 24h"
|
||||
value={formatPct(pct24)}
|
||||
unit={pct24 !== null ? "%" : undefined}
|
||||
tone={pct24 !== null && pct24 < 99 ? "text-danger" : ""}
|
||||
/>
|
||||
<Figure label="Uptime 30d" value={formatPct(pct30d)} unit={pct30d !== null ? "%" : undefined} />
|
||||
<Figure
|
||||
label="Response now"
|
||||
value={status === "down" ? "—" : formatMs(latency)}
|
||||
tone={status === "down" ? "text-danger" : ""}
|
||||
/>
|
||||
<Figure
|
||||
label="Certificate"
|
||||
value={certDays === null ? "n/a" : `${certDays}`}
|
||||
unit={certDays === null ? undefined : certDays === 1 ? " day left" : " days left"}
|
||||
tone={certDays !== null && certDays <= (monitor.target.tls_warn_days ?? 14) ? "text-warning" : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!incidents || incidents.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-secondary">No incidents recorded.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Started</Th>
|
||||
<Th>Resolved</Th>
|
||||
<Th>Cause</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{incidents.map((inc) => (
|
||||
<Tr key={inc.incident_id}>
|
||||
<Td label="Started">
|
||||
<span className="text-xs text-text-secondary">{new Date(inc.started_at).toLocaleString()}</span>
|
||||
</Td>
|
||||
<Td label="Resolved">
|
||||
{inc.resolved_at ? (
|
||||
<span className="text-xs text-text-secondary">{new Date(inc.resolved_at).toLocaleString()}</span>
|
||||
) : (
|
||||
<Badge variant="danger">ongoing</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td label="Cause">
|
||||
<span className="text-xs text-text-primary">{inc.cause || "n/a"}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Runner</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">{monitor.runner}</dd>
|
||||
<Panel
|
||||
title="Incidents"
|
||||
aside={incidents && incidents.length > 0 ? `${incidents.length} recorded${openIncidents ? ` · ${openIncidents} open` : ""}` : undefined}
|
||||
padded={false}
|
||||
>
|
||||
{!incidents || incidents.length === 0 ? (
|
||||
<p className="px-5 py-12 text-center text-sm text-text-secondary">
|
||||
No incidents recorded. Every check has passed since this monitor was created.
|
||||
</p>
|
||||
) : (
|
||||
incidents.map((inc) => <IncidentRow key={inc.incident_id} incident={inc} />)
|
||||
)}
|
||||
</Panel>
|
||||
</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>
|
||||
|
||||
<Panel title="Alerts">
|
||||
{alertChannels.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary">No one is told when this check fails.</p>
|
||||
) : (
|
||||
<dl className="text-[13px]">
|
||||
{alertChannels.map((c, i) => (
|
||||
<Row
|
||||
key={monitor.channel_ids?.[i]}
|
||||
label={c ? c.type : "channel"}
|
||||
value={c ? c.name : "Removed channel"}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
<Link href="/settings/notifications">
|
||||
<Button variant="secondary" size="sm" className="mt-4">
|
||||
Manage channels
|
||||
</Button>
|
||||
</Link>
|
||||
</Panel>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={`block h-2 w-2 rounded-full ${statusStripe[status]}`} />
|
||||
<p className="text-[13px] text-text-secondary">
|
||||
{status === "paused"
|
||||
? "Paused — no checks are running."
|
||||
: status === "down"
|
||||
? `Failing for ${monitor.state.fails} consecutive ${monitor.state.fails === 1 ? "check" : "checks"}.`
|
||||
: `Checked ${relativeTime(monitor.state.last_check_at)}.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Interval</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{monitor.interval_sec}s</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Retries before down</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{monitor.retries}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Target</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">
|
||||
{monitor.target.url || `${monitor.target.host ?? ""}${monitor.target.port ? `:${monitor.target.port}` : ""}`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,31 +4,35 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, MonitorInput } from "@/lib/api";
|
||||
import { Card } from "@/components/ui";
|
||||
import { MonitorForm } from "@/components/monitors/MonitorForm";
|
||||
|
||||
export default function NewMonitorPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: create, isPending, error } = useMutation({
|
||||
mutationFn: (input: MonitorInput) => api.createMonitor(input),
|
||||
onSuccess: (m) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
router.push(`/monitors/${m.monitor_id}`);
|
||||
},
|
||||
});
|
||||
const {
|
||||
mutate: create,
|
||||
isPending,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutationFn: (input: MonitorInput) => api.createMonitor(input),
|
||||
onSuccess: (m) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
router.push(`/monitors/${m.monitor_id}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">New Monitor</h1>
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<Link href="/monitors" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
<div className="mb-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">New check</p>
|
||||
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">Watch something</h1>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<MonitorForm submitLabel="Create Monitor" onSubmit={create} isPending={isPending} error={error as Error | null} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
<MonitorForm submitLabel="Create monitor" onSubmit={create} isPending={isPending} error={error as Error | null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+177
-96
@@ -1,109 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, Monitor, MonitorStatus } from "@/lib/api";
|
||||
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { api, Monitor, Rollup } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
DisplayStatus,
|
||||
StatusChip,
|
||||
Tape,
|
||||
avgLatency,
|
||||
buildSlots,
|
||||
displayStatus,
|
||||
formatMs,
|
||||
formatPct,
|
||||
relativeTime,
|
||||
statusStripe,
|
||||
targetSummary,
|
||||
uptimePct,
|
||||
} from "@/components/monitors/MonitorVisuals";
|
||||
|
||||
function statusVariant(status: MonitorStatus) {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return "success";
|
||||
case "down":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
/*
|
||||
* The list is a stack of channel strips rather than a table. Each row carries
|
||||
* 48 hours of history, so "is this healthy" is answered by the shape of the
|
||||
* tape and not by one status cell that was true when the page rendered.
|
||||
*
|
||||
* Uptime is per monitor, so the rollups are fetched per monitor. A fleet is
|
||||
* tens of checks, not thousands, and the alternative is a list endpoint that
|
||||
* embeds history for every row whether or not anyone looks at it.
|
||||
*/
|
||||
|
||||
const ROW = "grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1.15fr)_minmax(0,2fr)_170px] sm:gap-5";
|
||||
|
||||
function FleetMeter({ counts, uptime }: { counts: Record<DisplayStatus, number>; uptime: number | null }) {
|
||||
const total = counts.up + counts.down + counts.pending + counts.paused;
|
||||
const order: DisplayStatus[] = ["up", "pending", "down", "paused"];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 sm:px-5">
|
||||
<div className="mb-3 flex flex-wrap items-baseline gap-x-6 gap-y-2">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Fleet uptime, last 24 hours</p>
|
||||
<p className="mt-0.5 font-mono text-3xl font-semibold tabular-nums tracking-tight text-text-primary">
|
||||
{formatPct(uptime)}
|
||||
{uptime !== null && <span className="text-base text-text-tertiary">%</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 sm:ml-auto">
|
||||
{order.map((s) => (
|
||||
<span key={s} className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<span className={`block h-2.5 w-2.5 rounded-sm ${statusStripe[s]}`} />
|
||||
<span className="font-mono tabular-nums text-text-primary">{counts[s]}</span>
|
||||
{s === "pending" ? "pending" : s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-2 gap-0.5 overflow-hidden rounded-sm bg-well"
|
||||
role="img"
|
||||
aria-label={`${counts.up} up, ${counts.pending} pending, ${counts.down} down, ${counts.paused} paused of ${total} monitors`}
|
||||
>
|
||||
{order.map((s) =>
|
||||
counts[s] > 0 ? <span key={s} className={statusStripe[s]} style={{ flex: counts[s] }} /> : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ?? ""}`;
|
||||
function MonitorRow({ monitor, rollups }: { monitor: Monitor; rollups: Rollup[] }) {
|
||||
const status = displayStatus(monitor);
|
||||
const slots = buildSlots(rollups);
|
||||
const pct = uptimePct(rollups.slice(-24));
|
||||
const latency = monitor.state.latency_ms > 0 ? monitor.state.latency_ms : avgLatency(rollups.slice(-1));
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/monitors/${monitor.monitor_id}`}
|
||||
className={`${ROW} relative items-center border-t border-border-soft px-4 py-3.5 transition-colors first:border-t-0 hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5`}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 w-0.5 ${statusStripe[status]}`} aria-hidden />
|
||||
|
||||
<div className="min-w-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-text-primary">{monitor.name}</span>
|
||||
<span className="shrink-0 rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-secondary">
|
||||
{monitor.type}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate font-mono text-[11.5px] text-text-tertiary">{targetSummary(monitor)}</span>
|
||||
</div>
|
||||
|
||||
<Tape slots={slots} live={monitor.enabled} />
|
||||
|
||||
<div className="flex items-baseline gap-3 sm:block sm:text-right">
|
||||
<p className={`font-mono text-lg font-semibold tabular-nums ${status === "down" ? "text-danger" : "text-text-primary"}`}>
|
||||
{formatPct(pct)}
|
||||
{pct !== null && <span className="text-xs text-text-tertiary">%</span>}
|
||||
</p>
|
||||
<p className="font-mono text-[11px] text-text-tertiary sm:mt-1">
|
||||
<StatusChip status={status} className="mr-1.5 align-middle" />
|
||||
{status === "paused" ? "no checks" : `${formatMs(latency)} · ${relativeTime(monitor.state.last_check_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonitorsPage() {
|
||||
const { data: monitors, isLoading } = useQuery({
|
||||
queryKey: ["monitors"],
|
||||
queryFn: () => api.listMonitors(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const { data: monitors, isLoading } = useQuery({
|
||||
queryKey: ["monitors"],
|
||||
queryFn: () => api.listMonitors(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Monitors</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Service uptime and latency checks.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/settings/notifications">
|
||||
<Button variant="secondary">Notifications</Button>
|
||||
</Link>
|
||||
<Link href="/monitors/new">
|
||||
<Button variant="primary">New Monitor</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
const uptimeQueries = useQueries({
|
||||
queries: (monitors ?? []).map((m) => ({
|
||||
queryKey: ["monitors", m.monitor_id, "uptime"],
|
||||
queryFn: () => api.getMonitorUptime(m.monitor_id),
|
||||
refetchInterval: 60_000,
|
||||
})),
|
||||
});
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : !monitors || monitors.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-text-secondary">No monitors yet.</p>
|
||||
<Link href="/monitors/new">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Create your first monitor
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Type</Th>
|
||||
<Th>Target</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Latency</Th>
|
||||
<Th>Last check</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{monitors.map((m) => (
|
||||
<Tr key={m.monitor_id}>
|
||||
<Td label="Name">
|
||||
<Link href={`/monitors/${m.monitor_id}`} className="font-medium text-text-primary hover:text-accent">
|
||||
{m.name}
|
||||
const counts: Record<DisplayStatus, number> = { up: 0, down: 0, pending: 0, paused: 0 };
|
||||
for (const m of monitors ?? []) counts[displayStatus(m)] += 1;
|
||||
|
||||
const fleetRollups = uptimeQueries.flatMap((q) => (q.data ?? []).slice(-24));
|
||||
const fleetUptime = uptimePct(fleetRollups);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
|
||||
Fleet · {monitors?.length ?? 0} {monitors?.length === 1 ? "check" : "checks"}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">Monitors</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/settings/notifications">
|
||||
<Button variant="secondary">Notification channels</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td label="Type">
|
||||
<Badge variant="neutral">{m.type}</Badge>
|
||||
</Td>
|
||||
<Td label="Target">
|
||||
<span className="font-mono text-xs text-text-secondary">{targetSummary(m)}</span>
|
||||
</Td>
|
||||
<Td label="Status">
|
||||
<Badge variant={statusVariant(m.state.status)}>{m.state.status}</Badge>
|
||||
</Td>
|
||||
<Td label="Latency">
|
||||
<span className="text-sm text-text-secondary">{m.state.latency_ms}ms</span>
|
||||
</Td>
|
||||
<Td label="Last check">
|
||||
<span className="text-xs text-text-secondary">
|
||||
{m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "n/a"}
|
||||
</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
<Link href="/monitors/new">
|
||||
<Button variant="primary">New monitor</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : !monitors || monitors.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface px-6 py-14 text-center">
|
||||
<p className="text-[15px] font-semibold text-text-primary">Nothing is being watched yet.</p>
|
||||
<p className="mx-auto mt-2 max-w-[46ch] text-sm text-text-secondary">
|
||||
Add a check and Vantage records uptime and response time on your interval, opens an incident when it fails, and tells the
|
||||
channels you pick.
|
||||
</p>
|
||||
<Link href="/monitors/new">
|
||||
<Button variant="primary" className="mt-4">
|
||||
Add your first check
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FleetMeter counts={counts} uptime={fleetUptime} />
|
||||
|
||||
<div className={`${ROW} hidden px-5 pb-2 pt-5 sm:grid`}>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Monitor</p>
|
||||
<div className="flex items-end justify-between font-mono text-[10px] tracking-[0.08em] text-text-tertiary">
|
||||
<span>48h ago</span>
|
||||
<span>24h</span>
|
||||
<span>now</span>
|
||||
</div>
|
||||
<p className="text-right font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Uptime 24h · response</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
{monitors.map((m, i) => (
|
||||
<MonitorRow key={m.monitor_id} monitor={m} rollups={uptimeQueries[i]?.data ?? []} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user