conditionally render charts

This commit is contained in:
Alex Holliday committed 2026-07-28 15:27:19 -07:00
1 parent 4e495eed33
commit 81aab35961
3 files changed
+227 -77

No files matched your search

@@ -12,6 +12,7 @@ import {
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography";
import prettyMilliseconds from "pretty-ms";
import { useSelector } from "react-redux";
import {
@@ -68,12 +69,14 @@ const ResponseTimeToolTip = ({
if (!active) return null;
const format = tooltipDateFormatLookup(range);
console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography>Response time: {responseTime} ms</Typography>
<Typography>
Response time:{" "}
{prettyMilliseconds(responseTime, { formatSubMilliseconds: true, compact: true })}
</Typography>
</BaseBox>
);
};
@@ -95,7 +98,7 @@ export const HistogramDetails = ({
strokeWidth={1.5}
/>
}
title="Response times"
title="Average response time (First byte)"
>
<ResponsiveContainer
width="100%"
@@ -1,10 +1,36 @@
import type { GroupedUptimeCheck } from "@/Types/Check";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, YAxis } from "recharts";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import type { Theme } from "@mui/material/styles";
import type { RootState } from "@/Types/state";
import { Clock } from "lucide-react";
import prettyMilliseconds from "pretty-ms";
import Stack from "@mui/material/Stack";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
YAxis,
XAxis,
Text,
Tooltip,
} from "recharts";
import { BaseBox, BaseChart, Dot } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { useMemo } from "react";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
const PHASE_KEYS = [
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
@@ -13,6 +39,94 @@ const PHASE_KEYS = [
"avgDownload",
] as const;
const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
const PHASE_LABELS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "DNS",
avgTcp: "TCP",
avgTls: "TLS",
avgRequest: "Request",
avgFirstByte: "First byte",
avgDownload: "Download",
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: Theme;
uiTimezone: string;
};
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
const TimingPhasesToolTip = ({
active,
payload,
range,
theme,
uiTimezone,
}: TimingPhaseToolTipProps) => {
if (!active || !payload?.length) return null;
const bucket = payload[0].payload as GroupedUptimeCheck;
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
const format = tooltipDateFormatLookup(range);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
<Typography>Total: {prettyMilliseconds(total)}</Typography>
{PHASE_KEYS.map((key) => (
<Stack
alignItems={"center"}
direction="row"
key={key}
gap={SPACING.LG}
>
<Dot
size={"12px"}
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
/>
<Typography>
{PHASE_LABELS[key]}:{" "}
{prettyMilliseconds(bucket[key], {
formatSubMilliseconds: true,
compact: true,
})}
</Typography>
</Stack>
))}
</BaseBox>
);
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
export const HistogramDetailsStacked = ({
checks = [],
range,
@@ -21,83 +135,103 @@ export const HistogramDetailsStacked = ({
range: string;
}) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
// Cap the y-domain at the p95 of bucket totals so a single timeout bucket
// doesn't flatten every other bucket; spikes clip off the top instead.
// Cap the y domain at 2x the p95 of bucket totals, so typical stacks sit
// around mid-height with headroom above
const yMax = useMemo(() => {
const totals = checks
.map((check) => PHASE_KEYS.reduce((sum, key) => sum + check[key], 0))
.sort((a, b) => a - b);
if (totals.length < 2) return undefined;
const p95 = totals[Math.min(totals.length - 1, Math.floor(totals.length * 0.95))];
return p95 > 0 ? Math.ceil(p95) : undefined;
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
}, [checks]);
console.log(JSON.stringify(checks, null, 2));
return (
<ResponsiveContainer
width="100%"
height={300}
<BaseChart
icon={
<Clock
size={20}
strokeWidth={1.5}
/>
}
title="Timing phases"
>
<AreaChart data={checks.slice()}>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<YAxis
hide
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<Area
type="monotone"
stackId={1}
dataKey="avgDns"
stroke={"blue"}
fill="blue"
/>
<Area
stackId={1}
type="monotone"
dataKey="avgTcp"
stroke={"red"}
fill="red"
/>
<Area
stackId={1}
type="monotone"
dataKey="avgTls"
stroke={"green"}
fill="green"
/>
<Area
stackId={1}
type="monotone"
dataKey="avgRequest"
stroke={"orange"}
fill="orange"
/>
<Area
stackId={1}
type="monotone"
dataKey="avgFirstByte"
stroke={"purple"}
fill="purple"
/>
<Area
stackId={1}
type="monotone"
dataKey="avgDownload"
stroke={"brown"}
fill="brown"
/>
</AreaChart>
</ResponsiveContainer>
<ResponsiveContainer
width="100%"
height={300}
>
<AreaChart data={checks.slice()}>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis
axisLine={false}
tickLine={false}
dataKey="bucketDate"
tick={(props) => (
<XTick
{...props}
range={range}
/>
)}
/>
<Tooltip
content={(props) => (
<TimingPhasesToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
{PHASE_KEYS.map((key) => (
<Area
key={key}
type="monotone"
stackId={1}
dataKey={key}
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
fill={`url(#phaseGradient-${key})`}
/>
))}
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
+18 -5
View File
@@ -31,7 +31,10 @@ import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
import { Typography } from "@mui/material";
import { HistogramDetailsStacked } from "@/Components/monitors/charts/HistogramDetailsStacked";
import {
HistogramDetailsStacked,
PHASE_KEYS,
} from "@/Components/monitors/charts/HistogramDetailsStacked";
const certificateDateFormat = "MMM D, YYYY h A";
@@ -77,6 +80,14 @@ const UptimeDetailsPage = () => {
const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
const hasTimingPhases = useMemo(
() =>
(monitorData?.groupedChecks ?? []).some((check) =>
PHASE_KEYS.some((key) => check[key] > 0)
),
[monitorData]
);
// Certificate fetch - only for HTTP monitors
const certificateUrl = useMemo(() => {
if (!monitorId || monitor?.type !== "http") {
@@ -225,13 +236,15 @@ const UptimeDetailsPage = () => {
/>
</Stack>
<HistogramDetails
checks={monitorData?.groupedChecks || []}
range={dateRange}
/>
<HistogramDetailsStacked
checks={monitorData.groupedChecks}
range={dateRange}
/>
{hasTimingPhases && (
<HistogramDetailsStacked
checks={monitorData.groupedChecks}
range={dateRange}
/>
)}
<ChecksTable
checks={checks}
count={checksCount}