163 lines
6.5 KiB
TypeScript
163 lines
6.5 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import type { PollState } from "@/types/monitor";
|
|
|
|
type Tone = "mint" | "blue" | "coral";
|
|
|
|
const toneStyles: Record<Tone, string> = {
|
|
mint: "from-white via-white to-emerald-50 ring-emerald-100",
|
|
blue: "from-white via-white to-sky-50 ring-sky-100",
|
|
coral: "from-white via-white to-rose-50 ring-rose-100",
|
|
};
|
|
|
|
interface ECGMonitorCardProps<T> {
|
|
title: string;
|
|
endpoint: string;
|
|
intervalLabel: string;
|
|
tone?: Tone;
|
|
now: string;
|
|
wavePoints: string;
|
|
waveHeight: number;
|
|
healthy: boolean;
|
|
loading: boolean;
|
|
statusLabel: string;
|
|
statusDetail: string;
|
|
lastUpdatedLabel: string;
|
|
state: PollState<T>;
|
|
children?: ReactNode;
|
|
onManualTrigger: () => void;
|
|
}
|
|
|
|
/**
|
|
* Presentational card that renders endpoint status and the shared ECG waveform with
|
|
* consistent tone, status badges, and contextual metadata.
|
|
*/
|
|
export function ECGMonitorCard<T>({
|
|
title,
|
|
endpoint,
|
|
intervalLabel,
|
|
tone = "mint",
|
|
now,
|
|
wavePoints,
|
|
waveHeight,
|
|
healthy,
|
|
loading,
|
|
statusLabel,
|
|
statusDetail,
|
|
lastUpdatedLabel,
|
|
state,
|
|
children,
|
|
onManualTrigger,
|
|
}: ECGMonitorCardProps<T>) {
|
|
const signalId = `${endpoint.replace(/[^a-z0-9]/gi, "-")}-stroke`;
|
|
|
|
const badgeColor = loading
|
|
? "border-amber-200 bg-amber-50 text-amber-800"
|
|
: healthy
|
|
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
|
: "border-rose-200 bg-rose-50 text-rose-800";
|
|
|
|
const dotColor = loading ? "bg-pulse" : healthy ? "bg-success" : "bg-danger";
|
|
const strokeColor = loading ? "#f59e0b" : healthy ? "#22c55e" : "#ef4444";
|
|
|
|
return (
|
|
<article
|
|
className={`relative overflow-hidden rounded-3xl border border-black/5 bg-gradient-to-br ${toneStyles[tone]} p-5 shadow-card ring-4 ring-transparent transition hover:-translate-y-1 hover:shadow-[0_24px_60px_rgba(15,23,42,0.16)]`}
|
|
>
|
|
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_20%_10%,rgba(96,165,250,0.08),transparent_30%),radial-gradient(circle_at_80%_0%,rgba(52,211,153,0.1),transparent_30%)]" />
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="space-y-2 min-w-0">
|
|
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-black/5 bg-black/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-inkMuted">
|
|
<span className="break-words">{endpoint}</span>
|
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-gradient-to-r from-accent-blue to-accent-mint" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-ink">{title}</h3>
|
|
<p className="text-sm text-inkMuted">every {intervalLabel}</p>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 text-right">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold shadow-sm ${badgeColor}`}
|
|
>
|
|
<span className={`h-2.5 w-2.5 rounded-full ${dotColor}`} />
|
|
{statusLabel}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onManualTrigger}
|
|
className="inline-flex items-center gap-2 rounded-full bg-ink text-white px-3 py-2 text-xs font-semibold shadow-card transition hover:bg-inkSoft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
|
|
>
|
|
<span className="h-2 w-2 rounded-full bg-white" aria-hidden />
|
|
Poll now
|
|
</button>
|
|
</div>
|
|
<span className="text-xs font-semibold uppercase tracking-[0.08em] text-inkMuted">
|
|
{now}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-col gap-3">
|
|
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
|
|
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
|
|
Last updated
|
|
</p>
|
|
<p className="text-base font-semibold text-ink">{lastUpdatedLabel}</p>
|
|
</div>
|
|
|
|
<div className="relative overflow-hidden rounded-2xl border border-black/5 bg-gradient-to-br from-white via-white to-slate-50 p-4">
|
|
<div className="pointer-events-none absolute inset-0 opacity-60 mix-blend-multiply">
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_10%_20%,rgba(96,165,250,0.12),transparent_35%),radial-gradient(circle_at_80%_0%,rgba(52,211,153,0.12),transparent_30%)]" />
|
|
</div>
|
|
<div className="flex items-center justify-between text-xs text-inkMuted">
|
|
<span>ECG signal</span>
|
|
<span className="inline-flex items-center gap-2 rounded-full bg-black/5 px-2 py-1 font-semibold">
|
|
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
|
|
{loading ? "Polling" : healthy ? "Healthy" : "Unhealthy"}
|
|
</span>
|
|
</div>
|
|
<svg
|
|
viewBox="0 0 360 120"
|
|
preserveAspectRatio="none"
|
|
className="mt-2 h-28 w-full text-success drop-shadow-sm"
|
|
>
|
|
<defs>
|
|
<linearGradient id={signalId} x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.9" />
|
|
<stop offset="50%" stopColor={strokeColor} stopOpacity="0.75" />
|
|
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.95" />
|
|
</linearGradient>
|
|
</defs>
|
|
<polyline
|
|
points={wavePoints}
|
|
fill="none"
|
|
stroke={`url(#${signalId})`}
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
<circle
|
|
cx="360"
|
|
cy={waveHeight}
|
|
r="6"
|
|
className={`stroke-white/40 ${dotColor}`}
|
|
/>
|
|
</svg>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
|
|
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">Status</p>
|
|
<p className="text-base font-semibold text-ink">{statusDetail}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
|
|
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">Endpoint</p>
|
|
<p className="text-xs font-mono text-ink">{state.attemptedUrl}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{children}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|