feat: integrate chart for the web

This commit is contained in:
2026-08-28 11:41:50 +07:00
parent 9140397ec9
commit 2df694212a
5 changed files with 248 additions and 41 deletions
@@ -1,33 +1,88 @@
import { useEffect, useRef } from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
type TooltipContentProps,
} from "recharts";
import type { Check } from "../../api/monitors";
type LatencyDatum = {
t: string;
latency: number;
ok: boolean;
};
function LatencyTooltip({ active, payload }: TooltipContentProps<number, string>) {
if (!active || !payload.length) return null;
const point = payload[0].payload as LatencyDatum;
return (
<div className="chart-tooltip">
<time>{new Date(point.t).toLocaleString()}</time>
<strong>{point.latency} ms</strong>
<span className={point.ok ? "is-up" : "is-down"}>{point.ok ? "Up" : "Down"}</span>
</div>
);
}
export function LatencySparkline({ checks }: { checks: Check[] }) {
const values = checks.toReversed().map((check) => check.latencyMs);
if (values.length < 2) return <div className="chart-empty">More checks are needed to draw latency.</div>;
const width = 720;
const height = 180;
const padding = 12;
const hasAnimated = useRef(false);
const data: LatencyDatum[] = checks.toReversed().map((check) => ({
t: check.checkedAt,
latency: check.latencyMs,
ok: check.ok,
}));
const values = data.map((point) => point.latency);
useEffect(() => {
if (data.length >= 2) hasAnimated.current = true;
}, [data.length]);
if (data.length < 2) return <div className="chart-empty">More checks are needed to draw latency.</div>;
const minimum = Math.min(...values);
const maximum = Math.max(...values);
const range = Math.max(maximum - minimum, 1);
const points = values.map((value, index) => {
const x = padding + (index / (values.length - 1)) * (width - padding * 2);
const y = height - padding - ((value - minimum) / range) * (height - padding * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
return (
<div className="sparkline-wrap">
<svg className="latency-sparkline" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}>
<defs>
<linearGradient id="latency-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3ecf8e" stopOpacity="0.22" />
<stop offset="1" stopColor="#3ecf8e" stopOpacity="0" />
</linearGradient>
</defs>
<path className="chart-grid-line" d={`M 0 ${height * 0.25} H ${width} M 0 ${height * 0.5} H ${width} M 0 ${height * 0.75} H ${width}`} />
<polygon points={`${padding},${height - padding} ${points} ${width - padding},${height - padding}`} fill="url(#latency-fill)" />
<polyline points={points} fill="none" stroke="#24b47e" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
</svg>
<div
className="sparkline-wrap"
role="img"
aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 4, bottom: 4, left: 4 }}>
<defs>
<linearGradient id="latency-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3ecf8e" stopOpacity="0.22" />
<stop offset="1" stopColor="#3ecf8e" stopOpacity="0" />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#eeeeee" strokeDasharray="3 5" />
<XAxis dataKey="t" hide />
<YAxis hide domain={["dataMin", "dataMax"]} />
<Tooltip
content={LatencyTooltip}
cursor={{ stroke: "#9adfc1", strokeDasharray: "3 4" }}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="latency"
stroke="#24b47e"
strokeWidth={2.5}
fill="url(#latency-fill)"
activeDot={{ r: 4, fill: "#24b47e", stroke: "#fff", strokeWidth: 2 }}
isAnimationActive={!hasAnimated.current}
animationDuration={700}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
<div className="chart-scale"><span>{maximum} ms</span><span>{minimum} ms</span></div>
</div>
);