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