feat: Updated monitors pages
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m25s

This commit is contained in:
2026-08-04 12:19:15 +01:00
parent 287bd9657b
commit 80f0afb28b
7 changed files with 1077 additions and 479 deletions
+317 -187
View File
@@ -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>
);
}
+194
View File
@@ -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>
);
}