From 0cf8cfa5dc21dbe4bd210336e3b7a8d39ce4b60b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 28 Jul 2026 11:13:35 -0700 Subject: [PATCH 01/12] add avg timing phases to details response --- .../domain/checks/check.repository.mongo.ts | 21 ++++++++++++++++++- server/src/domain/checks/check.type.ts | 11 +++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 9767947d9..2374107d9 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -445,11 +445,30 @@ class MongoChecksRepository implements IChecksRepository { $dateToString: { format: dateString, date: "$createdAt" }, }, avgResponseTime: { $avg: "$responseTime" }, + avgDns: { $avg: "$timings.phases.dns" }, + avgTcp: { $avg: "$timings.phases.tcp" }, + avgTls: { $avg: "$timings.phases.tls" }, + avgRequest: { $avg: "$timings.phases.request" }, + avgFirstByte: { $avg: "$timings.phases.firstByte" }, + avgDownload: { $avg: "$timings.phases.download" }, totalChecks: { $sum: 1 }, }, }, { $sort: { _id: 1 } }, - { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, + { + $project: { + bucketDate: "$_id", + avgResponseTime: 1, + totalChecks: 1, + avgDns: { $ifNull: ["$avgDns", 0] }, + avgTcp: { $ifNull: ["$avgTcp", 0] }, + avgTls: { $ifNull: ["$avgTls", 0] }, + avgRequest: { $ifNull: ["$avgRequest", 0] }, + avgFirstByte: { $ifNull: ["$avgFirstByte", 0] }, + avgDownload: { $ifNull: ["$avgDownload", 0] }, + _id: 0, + }, + }, ], groupedUpChecks: [ { $match: { status: true } }, diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index faac84cef..911629e92 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -132,6 +132,15 @@ export interface GroupedCheck { totalChecks: number; } +export interface GroupedUptimeCheck extends GroupedCheck { + avgDns: number; + avgTcp: number; + avgTls: number; + avgRequest: number; + avgFirstByte: number; + avgDownload: number; +} + export interface PageSpeedGroupedCheck { bucketDate: string; performance: number; @@ -143,7 +152,7 @@ export interface PageSpeedGroupedCheck { export interface UptimeChecksResult { monitorType: Exclude; - groupedChecks: GroupedCheck[]; + groupedChecks: GroupedUptimeCheck[]; groupedUpChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[]; uptimePercentage: number; From 13c921531c6e77616c531bf55f3927f1936ae199 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 28 Jul 2026 11:13:50 -0700 Subject: [PATCH 02/12] update types, add skeleton for stacked timing phase chart --- .../charts/HistogramDetailsStacked.tsx | 39 +++++++++++++++++++ client/src/Pages/Uptime/Details/index.tsx | 16 +++++++- client/src/Types/Check.ts | 9 +++++ client/src/Types/Monitor.ts | 4 +- 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 client/src/Components/monitors/charts/HistogramDetailsStacked.tsx diff --git a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx new file mode 100644 index 000000000..a47af1a0d --- /dev/null +++ b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx @@ -0,0 +1,39 @@ +import type { GroupedUptimeCheck } from "@/Types/Check"; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts"; + +import { useTheme } from "@mui/material"; + +export const HistogramDetailsStacked = ({ + checks = [], + range, +}: { + checks: GroupedUptimeCheck[]; + range: string; +}) => { + const theme = useTheme(); + + console.log(JSON.stringify(checks, null, 2)); + + return ( + + + + + + + ); +}; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 8828aa29a..7aa0b1046 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -31,6 +31,7 @@ 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"; const certificateDateFormat = "MMM D, YYYY h A"; @@ -72,6 +73,7 @@ const UptimeDetailsPage = () => { ); const monitorData = monitorDetailsData?.monitorData; + const monitor = monitorData?.monitor; const monitorStats = monitorDetailsData?.monitorStats ?? null; @@ -165,10 +167,18 @@ const UptimeDetailsPage = () => { { keepPreviousData: true, revalidateOnFocus: false } ); + if (!monitorData) { + return null; + } + + if (!monitor) { + return null; + } + const geoChecksForTable = geoChecksTableData?.geoChecks ?? []; const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0; - const geoLocations = monitor?.geoCheckLocations; + const geoLocations = monitor.geoCheckLocations; const checks = checksData?.checks ?? []; const checksCount = checksData?.checksCount ?? 0; @@ -218,6 +228,10 @@ const UptimeDetailsPage = () => { checks={monitorData?.groupedChecks || []} range={dateRange} /> + Date: Tue, 28 Jul 2026 11:24:23 -0700 Subject: [PATCH 03/12] add area charts --- .../charts/HistogramDetailsStacked.tsx | 72 +++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx index a47af1a0d..81ca5a103 100644 --- a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx +++ b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx @@ -1,8 +1,18 @@ import type { GroupedUptimeCheck } from "@/Types/Check"; -import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts"; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, YAxis } from "recharts"; +import { useMemo } from "react"; import { useTheme } from "@mui/material"; +const PHASE_KEYS = [ + "avgDns", + "avgTcp", + "avgTls", + "avgRequest", + "avgFirstByte", + "avgDownload", +] as const; + export const HistogramDetailsStacked = ({ checks = [], range, @@ -12,6 +22,17 @@ export const HistogramDetailsStacked = ({ }) => { const theme = useTheme(); + // 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. + 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; + }, [checks]); + console.log(JSON.stringify(checks, null, 2)); return ( @@ -27,11 +48,54 @@ export const HistogramDetailsStacked = ({ fill="transparent" vertical={false} /> + + + + + + + + From 061da5e7ef0db9b0939f23fa6f18be3a72433f7b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 28 Jul 2026 15:26:52 -0700 Subject: [PATCH 04/12] coerce values to 0 if null --- .../domain/checks/check.repository.mongo.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 2374107d9..fcc967828 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -445,12 +445,12 @@ class MongoChecksRepository implements IChecksRepository { $dateToString: { format: dateString, date: "$createdAt" }, }, avgResponseTime: { $avg: "$responseTime" }, - avgDns: { $avg: "$timings.phases.dns" }, - avgTcp: { $avg: "$timings.phases.tcp" }, - avgTls: { $avg: "$timings.phases.tls" }, - avgRequest: { $avg: "$timings.phases.request" }, - avgFirstByte: { $avg: "$timings.phases.firstByte" }, - avgDownload: { $avg: "$timings.phases.download" }, + avgDns: { $avg: { $ifNull: ["$timings.phases.dns", 0] } }, + avgTcp: { $avg: { $ifNull: ["$timings.phases.tcp", 0] } }, + avgTls: { $avg: { $ifNull: ["$timings.phases.tls", 0] } }, + avgRequest: { $avg: { $ifNull: ["$timings.phases.request", 0] } }, + avgFirstByte: { $avg: { $ifNull: ["$timings.phases.firstByte", 0] } }, + avgDownload: { $avg: { $ifNull: ["$timings.phases.download", 0] } }, totalChecks: { $sum: 1 }, }, }, @@ -460,12 +460,12 @@ class MongoChecksRepository implements IChecksRepository { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, - avgDns: { $ifNull: ["$avgDns", 0] }, - avgTcp: { $ifNull: ["$avgTcp", 0] }, - avgTls: { $ifNull: ["$avgTls", 0] }, - avgRequest: { $ifNull: ["$avgRequest", 0] }, - avgFirstByte: { $ifNull: ["$avgFirstByte", 0] }, - avgDownload: { $ifNull: ["$avgDownload", 0] }, + avgDns: 1, + avgTcp: 1, + avgTls: 1, + avgRequest: 1, + avgFirstByte: 1, + avgDownload: 1, _id: 0, }, }, From 4e495eed33576c281712aff888942947709c6dfa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 28 Jul 2026 15:27:04 -0700 Subject: [PATCH 05/12] add chart colors to theme --- client/src/Utils/Theme/Palette.ts | 31 +++++++++++++++++++++++++++++++ client/src/Utils/Theme/Theme.ts | 20 ++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index 55dc1e26e..5257ab92b 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -20,8 +20,19 @@ export const colors = { brandGreen: "#13715B", brandGreenLight: "#4DAF94", green200: "#ECF7F2", + green700: "#008300", yellow200: "#FFF4E5", + yellow500: "#EDA100", + yellow600: "#C98500", orange500: "#FFA500", + orange600: "#EB6834", + orange700: "#D95926", + blue400: "#3987E5", + blue500: "#2A78D6", + aqua500: "#1BAF7A", + aqua600: "#199E70", + magenta300: "#E87BA4", + magenta400: "#D55181", }; export const lightPalette = { @@ -38,6 +49,16 @@ export const lightPalette = { running: colors.green200, paused: colors.yellow200, }, + chart: { + phases: { + dns: colors.magenta300, + tcp: colors.orange600, + tls: colors.aqua500, + request: colors.yellow500, + firstByte: colors.blue500, + download: colors.green700, + }, + }, }; export const darkPalette = { @@ -58,4 +79,14 @@ export const darkPalette = { running: alpha(colors.brandGreen, 0.18), paused: alpha(colors.orange500, 0.18), }, + chart: { + phases: { + dns: colors.magenta400, + tcp: colors.orange700, + tls: colors.aqua600, + request: colors.yellow600, + firstByte: colors.blue500, + download: colors.green700, + }, + }, }; diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index 04f4c94eb..33ba66945 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -16,10 +16,30 @@ declare module "@mui/material/styles" { interface Palette { sidebar: { accent: string }; rowStatus: { running: string; paused: string }; + chart: { + phases: { + dns: string; + tcp: string; + tls: string; + request: string; + firstByte: string; + download: string; + }; + }; } interface PaletteOptions { sidebar?: { accent: string }; rowStatus?: { running: string; paused: string }; + chart?: { + phases: { + dns: string; + tcp: string; + tls: string; + request: string; + firstByte: string; + download: string; + }; + }; } } From 81aab3596103d4f458fa2c7e7534a5c88ac5adfd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 28 Jul 2026 15:27:19 -0700 Subject: [PATCH 06/12] conditionally render charts --- .../monitors/charts/HistogramDetails.tsx | 9 +- .../charts/HistogramDetailsStacked.tsx | 272 +++++++++++++----- client/src/Pages/Uptime/Details/index.tsx | 23 +- 3 files changed, 227 insertions(+), 77 deletions(-) diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index bd78f3289..17849fadb 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -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 ( {formatDateWithTz(String(label), format, uiTimezone)} - Response time: {responseTime} ms + + Response time:{" "} + {prettyMilliseconds(responseTime, { formatSubMilliseconds: true, compact: true })} + ); }; @@ -95,7 +98,7 @@ export const HistogramDetails = ({ strokeWidth={1.5} /> } - title="Response times" + title="Average response time (First byte)" > = { + avgDns: "DNS", + avgTcp: "TCP", + avgTls: "TLS", + avgRequest: "Request", + avgFirstByte: "First byte", + avgDownload: "Download", +}; + +type TimingPhaseToolTipProps = TooltipProps & { + 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 ( + + {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} + Total: {prettyMilliseconds(total)} + {PHASE_KEYS.map((key) => ( + + + + {PHASE_LABELS[key]}:{" "} + {prettyMilliseconds(bucket[key], { + formatSubMilliseconds: true, + compact: true, + })} + + + ))} + + ); +}; + +export const XTick = ({ x, y, payload, range }: XTickProps) => { + const format = tickDateFormatLookup(range); + const theme = useTheme(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + return ( + + {formatDateWithTz(payload?.value, format, uiTimezone)} + + ); +}; + 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 ( - + } + title="Timing phases" > - - - - - - - - - - - - - + + + + + {PHASE_KEYS.map((key) => ( + + + + + ))} + + + ( + + )} + /> + ( + + )} + /> + {PHASE_KEYS.map((key) => ( + + ))} + + + ); }; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 7aa0b1046..04d207d06 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -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 = () => { /> - + {hasTimingPhases && ( + + )} Date: Wed, 29 Jul 2026 10:57:20 -0700 Subject: [PATCH 07/12] denormalize resposne times --- .../Components/monitors/charts/ChartUtils.ts | 6 ++ .../monitors/charts/HistogramDetails.tsx | 15 ++++- .../charts/HistogramDetailsStacked.tsx | 11 ++-- client/src/Pages/Uptime/Details/index.tsx | 1 - client/src/Types/Check.ts | 4 -- .../src/api/validation/monitorValidation.ts | 1 - server/src/domain/checks/check.type.ts | 4 -- server/src/domain/monitors/monitor.service.ts | 8 +-- server/src/utils/dataUtils.ts | 16 +----- server/test/unit/utils/dataUtils.test.ts | 57 +------------------ 10 files changed, 31 insertions(+), 92 deletions(-) create mode 100644 client/src/Components/monitors/charts/ChartUtils.ts diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.ts new file mode 100644 index 000000000..27c36d38b --- /dev/null +++ b/client/src/Components/monitors/charts/ChartUtils.ts @@ -0,0 +1,6 @@ +export const computeYAxisCap = (values: number[]): number | undefined => { + const sorted = values.slice().sort((a, b) => a - b); + if (sorted.length < 2) return undefined; + const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; + return p95 > 0 ? Math.ceil(p95 * 2) : undefined; +}; diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index 17849fadb..29bf63f09 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -4,6 +4,7 @@ import { AreaChart, Area, XAxis, + YAxis, Tooltip, CartesianGrid, ResponsiveContainer, @@ -24,6 +25,8 @@ import { useTheme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import type { GroupedCheck } from "@/Types/Check"; import type { RootState } from "@/Types/state"; +import { useMemo } from "react"; +import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; type XTickProps = { x: number; @@ -69,7 +72,7 @@ const ResponseTimeToolTip = ({ if (!active) return null; const format = tooltipDateFormatLookup(range); - const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0); + const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0); return ( {formatDateWithTz(String(label), format, uiTimezone)} @@ -90,6 +93,10 @@ export const HistogramDetails = ({ }) => { const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const yMax = useMemo(() => { + const totals = checks.map((check) => check.avgResponseTime || 0); + return computeYAxisCap(totals); + }, [checks]); return ( + { - 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 * 2) : undefined; + const totals = checks.map((check) => + PHASE_KEYS.reduce((sum, key) => sum + check[key], 0) + ); + return computeYAxisCap(totals); }, [checks]); return ( diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 04d207d06..c4128e5e4 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -61,7 +61,6 @@ const UptimeDetailsPage = () => { } const params = new URLSearchParams(); params.append("dateRange", dateRange); - params.append("normalize", "true"); return `/monitors/uptime/details/${monitorId}?${params.toString()}`; }, [monitorId, dateRange]); diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index f629bd122..cfd072944 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -261,7 +261,3 @@ export interface HasResponseTime { export type NormalizedCheck = T & { originalResponseTime: number; }; - -export type NormalizedUptimeCheck = T & { - originalAvgResponseTime: number; -}; diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 37a31fc6a..0e8b7b0bd 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -197,7 +197,6 @@ export const getUptimeDetailsByIdParamValidation = z.object({ export const getUptimeDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges), - normalize: booleanCoercion.optional(), }); const importedMonitorSchema = z diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 911629e92..d4c47cfb9 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -172,10 +172,6 @@ export type NormalizedCheck = T & { originalResponseTime: number; }; -export type NormalizedUptimeCheck = T & { - originalAvgResponseTime: number; -}; - export type CheckSnapshot = Omit; export interface HardwareDiskStats { diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 178da5225..29046f83d 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -1,4 +1,4 @@ -import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js"; +import { NormalizeData } from "@/utils/dataUtils.js"; import { type Monitor } from "@/domain/monitors/monitor.type.js"; import type { MonitorType, @@ -193,9 +193,9 @@ export class MonitorService implements IMonitorService { return { monitorData: { monitor, - groupedChecks: NormalizeDataUptimeDetails(checksData.groupedChecks, 10, 100), - groupedUpChecks: NormalizeDataUptimeDetails(checksData.groupedUpChecks, 10, 100), - groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100), + groupedChecks: checksData.groupedChecks, + groupedUpChecks: checksData.groupedUpChecks, + groupedDownChecks: checksData.groupedDownChecks, groupedAvgResponseTime: checksData.avgResponseTime, groupedUptimePercentage: checksData.uptimePercentage, }, diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts index 0382abc57..2c15b943e 100755 --- a/server/src/utils/dataUtils.ts +++ b/server/src/utils/dataUtils.ts @@ -1,4 +1,4 @@ -import type { GroupedCheck, HasResponseTime, NormalizedCheck, NormalizedUptimeCheck } from "@/domain/checks/check.type.js"; +import type { HasResponseTime, NormalizedCheck } from "@/domain/checks/check.type.js"; import { type DateRange } from "@/types/query.js"; export const getDateForRange = (dateRange: DateRange): Date => { @@ -61,17 +61,3 @@ export const NormalizeData = (checks: T[], rangeMin: originalResponseTime: original(check), })); }; - -export const NormalizeDataUptimeDetails = (checks: T[], rangeMin: number, rangeMax: number): NormalizedUptimeCheck[] => { - const original = (check: T) => (typeof check.avgResponseTime === "number" ? check.avgResponseTime : 0); - if (checks.length <= 1) { - return checks.map((check) => ({ ...check, originalAvgResponseTime: original(check) })); - } - const min = percentileBy(checks, 0, (c) => c.avgResponseTime); - const max = percentileBy(checks, 95, (c) => c.avgResponseTime); - return checks.map((check) => ({ - ...check, - avgResponseTime: rescale(check.avgResponseTime, min, max, rangeMin, rangeMax), - originalAvgResponseTime: original(check), - })); -}; diff --git a/server/test/unit/utils/dataUtils.test.ts b/server/test/unit/utils/dataUtils.test.ts index 01f102cca..af9434bab 100644 --- a/server/test/unit/utils/dataUtils.test.ts +++ b/server/test/unit/utils/dataUtils.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { getDateForRange, getDateFormat, NormalizeData, NormalizeDataUptimeDetails } from "../../../src/utils/dataUtils.ts"; -import type { GroupedCheck } from "../../../src/domain/checks/check.type.ts"; +import { getDateForRange, getDateFormat, NormalizeData } from "../../../src/utils/dataUtils.ts"; import { DateRanges, type DateRange } from "../../../src/types/query.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -12,13 +11,6 @@ const makeCheck = (responseTime: number, overrides?: Record) => ...overrides, }); -const makeGrouped = (avgResponseTime: number, overrides?: Partial): GroupedCheck => ({ - bucketDate: `bucket-${avgResponseTime}`, - avgResponseTime, - totalChecks: 5, - ...overrides, -}); - // ── Tests ──────────────────────────────────────────────────────────────────── describe("getDateForRange", () => { @@ -126,50 +118,3 @@ describe("NormalizeData", () => { }); }); }); - -describe("NormalizeDataUptimeDetails", () => { - it("rescales avgResponseTime into [rangeMin, rangeMax] anchored to the p0–p95 window", () => { - const checks = [100, 200, 300, 400, 500].map((avg) => makeGrouped(avg)); - - const result = NormalizeDataUptimeDetails(checks, 10, 100); - - expect(result[0]!.avgResponseTime).toBe(10); - expect(result[1]!.avgResponseTime).toBeCloseTo(33.6842, 4); - expect(result[4]!.avgResponseTime).toBe(100); - }); - - it("preserves the raw value in originalAvgResponseTime and spreads other fields", () => { - const checks = [makeGrouped(100), makeGrouped(500)]; - - const result = NormalizeDataUptimeDetails(checks, 10, 100); - - expect(result[0]!.originalAvgResponseTime).toBe(100); - expect(result[1]!.originalAvgResponseTime).toBe(500); - expect(result[0]).toMatchObject({ bucketDate: "bucket-100", totalChecks: 5 }); - }); - - it("passes a single bucket through, only adding originalAvgResponseTime", () => { - const result = NormalizeDataUptimeDetails([makeGrouped(250)], 10, 100); - - expect(result).toEqual([{ bucketDate: "bucket-250", avgResponseTime: 250, totalChecks: 5, originalAvgResponseTime: 250 }]); - }); - - it("returns an empty array for empty input", () => { - expect(NormalizeDataUptimeDetails([], 10, 100)).toEqual([]); - }); - - it("records originalAvgResponseTime as 0 for a null avgResponseTime ($avg over an empty bucket)", () => { - const result = NormalizeDataUptimeDetails([makeGrouped(null as unknown as number)], 10, 100); - - expect(result[0]!.originalAvgResponseTime).toBe(0); - }); - - it("produces NaN when all values are equal (division by zero — known edge case)", () => { - const result = NormalizeDataUptimeDetails([makeGrouped(50), makeGrouped(50)], 10, 100); - - result.forEach((check) => { - expect(check.avgResponseTime).toBeNaN(); - expect(check.originalAvgResponseTime).toBe(50); - }); - }); -}); From 58f1ae7fe9293545d6962a288c074f69d1d3a538 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 29 Jul 2026 11:04:11 -0700 Subject: [PATCH 08/12] extract xtick --- .../charts/{ChartUtils.ts => ChartUtils.tsx} | 0 .../monitors/charts/HistogramDetails.tsx | 34 ++----------------- .../charts/HistogramDetailsStacked.tsx | 33 ++---------------- .../charts/HistogramInfrastructure.tsx | 2 +- .../charts/HistogramPageSpeedDetails.tsx | 2 +- .../src/Components/monitors/charts/XTick.tsx | 30 ++++++++++++++++ 6 files changed, 36 insertions(+), 65 deletions(-) rename client/src/Components/monitors/charts/{ChartUtils.ts => ChartUtils.tsx} (100%) create mode 100644 client/src/Components/monitors/charts/XTick.tsx diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.tsx similarity index 100% rename from client/src/Components/monitors/charts/ChartUtils.ts rename to client/src/Components/monitors/charts/ChartUtils.tsx diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index 29bf63f09..3ad0e03b3 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -8,19 +8,14 @@ import { Tooltip, CartesianGrid, ResponsiveContainer, - Text, } from "recharts"; 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 { XTick } from "@/Components/monitors/charts/XTick"; import { useSelector } from "react-redux"; -import { - formatDateWithTz, - tickDateFormatLookup, - tooltipDateFormatLookup, -} from "@/Utils/TimeUtils"; +import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; import { useTheme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import type { GroupedCheck } from "@/Types/Check"; @@ -28,31 +23,6 @@ import type { RootState } from "@/Types/state"; import { useMemo } from "react"; import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; -type XTickProps = { - x: number; - y: number; - payload: { value: string }; - range: string; -}; - -export const XTick = ({ x, y, payload, range }: XTickProps) => { - const format = tickDateFormatLookup(range); - const theme = useTheme(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - return ( - - {formatDateWithTz(payload?.value, format, uiTimezone)} - - ); -}; - type ResponseTimeToolTipProps = TooltipProps & { range: string; theme: Theme; diff --git a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx index 46377cedd..2ef74f56b 100644 --- a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx +++ b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx @@ -14,17 +14,13 @@ import { ResponsiveContainer, YAxis, XAxis, - Text, Tooltip, } from "recharts"; import { BaseBox, BaseChart, Dot } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; +import { XTick } from "@/Components/monitors/charts/XTick"; -import { - formatDateWithTz, - tickDateFormatLookup, - tooltipDateFormatLookup, -} from "@/Utils/TimeUtils"; +import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; import { useSelector } from "react-redux"; import { useMemo } from "react"; import { useTheme } from "@mui/material"; @@ -64,13 +60,6 @@ type TimingPhaseToolTipProps = TooltipProps & { uiTimezone: string; }; -type XTickProps = { - x: number; - y: number; - payload: { value: string }; - range: string; -}; - const TimingPhasesToolTip = ({ active, payload, @@ -110,24 +99,6 @@ const TimingPhasesToolTip = ({ ); }; -export const XTick = ({ x, y, payload, range }: XTickProps) => { - const format = tickDateFormatLookup(range); - const theme = useTheme(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - return ( - - {formatDateWithTz(payload?.value, format, uiTimezone)} - - ); -}; - export const HistogramDetailsStacked = ({ checks = [], range, diff --git a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx index 8dd15ae03..8163898aa 100644 --- a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx +++ b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx @@ -8,7 +8,7 @@ import { ResponsiveContainer, } from "recharts"; import { Fragment, useId } from "react"; -import { XTick } from "@/Components/monitors"; +import { XTick } from "@/Components/monitors/charts/XTick"; import { useTheme } from "@mui/material/styles"; import type { HardwareCheckStats } from "@/Types/Monitor"; diff --git a/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx b/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx index f89994437..22001602c 100644 --- a/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx @@ -1,6 +1,6 @@ import { BaseChart } from "@/Components/design-elements"; import { TrendingUp } from "lucide-react"; -import { XTick } from "@/Components/monitors"; +import { XTick } from "@/Components/monitors/charts/XTick"; import { XAxis, AreaChart, diff --git a/client/src/Components/monitors/charts/XTick.tsx b/client/src/Components/monitors/charts/XTick.tsx new file mode 100644 index 000000000..9cb57ffab --- /dev/null +++ b/client/src/Components/monitors/charts/XTick.tsx @@ -0,0 +1,30 @@ +import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils"; +import { useSelector } from "react-redux"; +import { Text } from "recharts"; +import { useTheme } from "@mui/material"; +import type { RootState } from "@/Types/state"; + +type XTickProps = { + x: number; + y: number; + payload: { value: string }; + range: string; +}; + +export const XTick = ({ x, y, payload, range }: XTickProps) => { + const format = tickDateFormatLookup(range); + const theme = useTheme(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + return ( + + {formatDateWithTz(payload?.value, format, uiTimezone)} + + ); +}; From 0e0cace676d4a31ce8b57c87891212897a3786aa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 29 Jul 2026 11:45:49 -0700 Subject: [PATCH 09/12] merge charts --- .../Components/design-elements/BaseChart.tsx | 7 + .../Components/monitors/charts/ChartUtils.ts | 33 +++ .../Components/monitors/charts/ChartUtils.tsx | 6 - .../monitors/charts/HistogramDetails.tsx | 158 +++++++++++-- .../charts/HistogramDetailsStacked.tsx | 207 ------------------ client/src/Pages/Uptime/Details/index.tsx | 18 -- 6 files changed, 178 insertions(+), 251 deletions(-) create mode 100644 client/src/Components/monitors/charts/ChartUtils.ts delete mode 100644 client/src/Components/monitors/charts/ChartUtils.tsx delete mode 100644 client/src/Components/monitors/charts/HistogramDetailsStacked.tsx diff --git a/client/src/Components/design-elements/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx index ddfd82a3e..46abe5d39 100644 --- a/client/src/Components/design-elements/BaseChart.tsx +++ b/client/src/Components/design-elements/BaseChart.tsx @@ -13,6 +13,7 @@ type BaseChartProps = React.PropsWithChildren<{ width?: number | string; maxWidth?: number | string; padding?: number | string | ResponsiveStyleValue; + onClick?: () => void; }>; export const BaseChart = ({ @@ -21,16 +22,22 @@ export const BaseChart = ({ width = "100%", maxWidth = "100%", padding, + onClick, }: BaseChartProps) => { const theme = useTheme(); return ( { + const sorted = values.slice().sort((a, b) => a - b); + if (sorted.length < 2) return undefined; + const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; + return p95 > 0 ? Math.ceil(p95 * 2) : undefined; +}; + +export const PHASE_KEYS = [ + "avgDns", + "avgTcp", + "avgTls", + "avgRequest", + "avgFirstByte", + "avgDownload", +] as const; + +export const PHASE_COLOR_KEYS = { + avgDns: "dns", + avgTcp: "tcp", + avgTls: "tls", + avgRequest: "request", + avgFirstByte: "firstByte", + avgDownload: "download", +} as const; + +export const PHASE_LABELS: Record<(typeof PHASE_KEYS)[number], string> = { + avgDns: "DNS", + avgTcp: "TCP", + avgTls: "TLS", + avgRequest: "Request", + avgFirstByte: "First byte", + avgDownload: "Download", +}; diff --git a/client/src/Components/monitors/charts/ChartUtils.tsx b/client/src/Components/monitors/charts/ChartUtils.tsx deleted file mode 100644 index 27c36d38b..000000000 --- a/client/src/Components/monitors/charts/ChartUtils.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export const computeYAxisCap = (values: number[]): number | undefined => { - const sorted = values.slice().sort((a, b) => a - b); - if (sorted.length < 2) return undefined; - const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; - return p95 > 0 ? Math.ceil(p95 * 2) : undefined; -}; diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index 3ad0e03b3..5c3970d3c 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -1,5 +1,7 @@ +import Stack from "@mui/material/Stack"; import { BaseChart, BaseBox } from "@/Components/design-elements"; import { Clock } from "lucide-react"; +import { Dot } from "@/Components/design-elements"; import { AreaChart, Area, @@ -18,11 +20,21 @@ import { useSelector } from "react-redux"; import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; import { useTheme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; -import type { GroupedCheck } from "@/Types/Check"; +import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check"; import type { RootState } from "@/Types/state"; -import { useMemo } from "react"; import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; +import { useMemo, useState } from "react"; +import { SPACING } from "@/Utils/Theme/constants"; +import { + PHASE_COLOR_KEYS, + PHASE_KEYS, + PHASE_LABELS, +} from "@/Components/monitors/charts/ChartUtils"; + +type DetailsCheck = GroupedCheck & + Partial>; + type ResponseTimeToolTipProps = TooltipProps & { range: string; theme: Theme; @@ -54,19 +66,71 @@ const ResponseTimeToolTip = ({ ); }; +type TimingPhaseToolTipProps = TooltipProps & { + range: string; + theme: Theme; + uiTimezone: 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 ( + + {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} + Total: {prettyMilliseconds(total)} + {PHASE_KEYS.map((key) => ( + + + + {PHASE_LABELS[key]}:{" "} + {prettyMilliseconds(bucket[key], { + formatSubMilliseconds: true, + compact: true, + })} + + + ))} + + ); +}; + export const HistogramDetails = ({ checks, range, }: { - checks: GroupedCheck[]; + checks: DetailsCheck[]; range: string; }) => { + const [showTimingPhases, setShowTimingPhases] = useState(false); const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const yMax = useMemo(() => { const totals = checks.map((check) => check.avgResponseTime || 0); return computeYAxisCap(totals); }, [checks]); + + const hasTimingPhases = useMemo( + () => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)), + [checks] + ); + return ( } - title="Average response time (First byte)" + title={showTimingPhases ? "Timing Phases" : "Response Time"} + onClick={() => { + if (!hasTimingPhases) return; + setShowTimingPhases((prev) => !prev); + }} > + {PHASE_KEYS.map((key) => ( + + + + + ))} - ( - - )} - /> - + {showTimingPhases ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )} + {!showTimingPhases && ( + + )} + {hasTimingPhases && + showTimingPhases && + PHASE_KEYS.map((key) => { + return ( + + ); + })} diff --git a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx deleted file mode 100644 index 2ef74f56b..000000000 --- a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import type { GroupedUptimeCheck } from "@/Types/Check"; -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, - Tooltip, -} from "recharts"; -import { BaseBox, BaseChart, Dot } from "@/Components/design-elements"; -import Typography from "@mui/material/Typography"; -import { XTick } from "@/Components/monitors/charts/XTick"; - -import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; -import { useSelector } from "react-redux"; -import { useMemo } from "react"; -import { useTheme } from "@mui/material"; -import { SPACING } from "@/Utils/Theme/constants"; -import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; - -export const PHASE_KEYS = [ - "avgDns", - "avgTcp", - "avgTls", - "avgRequest", - "avgFirstByte", - "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 & { - range: string; - theme: Theme; - uiTimezone: 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 ( - - {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} - Total: {prettyMilliseconds(total)} - {PHASE_KEYS.map((key) => ( - - - - {PHASE_LABELS[key]}:{" "} - {prettyMilliseconds(bucket[key], { - formatSubMilliseconds: true, - compact: true, - })} - - - ))} - - ); -}; - -export const HistogramDetailsStacked = ({ - checks = [], - range, -}: { - checks: GroupedUptimeCheck[]; - range: string; -}) => { - const theme = useTheme(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - - // 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) - ); - return computeYAxisCap(totals); - }, [checks]); - - return ( - - } - title="Timing phases" - > - - - - - {PHASE_KEYS.map((key) => ( - - - - - ))} - - - ( - - )} - /> - ( - - )} - /> - {PHASE_KEYS.map((key) => ( - - ))} - - - - ); -}; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index c4128e5e4..56724259a 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -31,10 +31,6 @@ import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { t } from "i18next"; import { Typography } from "@mui/material"; -import { - HistogramDetailsStacked, - PHASE_KEYS, -} from "@/Components/monitors/charts/HistogramDetailsStacked"; const certificateDateFormat = "MMM D, YYYY h A"; @@ -79,14 +75,6 @@ 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") { @@ -238,12 +226,6 @@ const UptimeDetailsPage = () => { checks={monitorData.groupedChecks} range={dateRange} /> - {hasTimingPhases && ( - - )} Date: Wed, 29 Jul 2026 12:30:18 -0700 Subject: [PATCH 10/12] first byte to total repsonse --- .../0009_recomputeResponseTimeFromTimings.ts | 209 ++++++++++++++++++ server/src/db/migration/index.ts | 2 + server/src/service/network/HttpProvider.ts | 4 +- 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 server/src/db/migration/0009_recomputeResponseTimeFromTimings.ts diff --git a/server/src/db/migration/0009_recomputeResponseTimeFromTimings.ts b/server/src/db/migration/0009_recomputeResponseTimeFromTimings.ts new file mode 100644 index 000000000..e65812530 --- /dev/null +++ b/server/src/db/migration/0009_recomputeResponseTimeFromTimings.ts @@ -0,0 +1,209 @@ +import mongoose from "mongoose"; +import type { Document } from "mongodb"; +import type { ILogger } from "@/utils/logger.js"; + +const SERVICE_NAME = "Migration:RecomputeResponseTimeFromTimings"; +const CHECKS_COLLECTION = "checks"; +const BACKUP_COLLECTION = "checks_backup_response_time"; +const FAILED_DOCS_COLLECTION = "checks_response_time_migration_failed"; +const BATCH_SIZE = 1000; +const NAMESPACE_EXISTS_CODE = 48; + +interface MigrationStats { + totalSource: number; + totalMigrated: number; + totalFailed: number; +} + +const getDb = () => { + const db = mongoose.connection.db; + if (!db) { + throw new Error("Database connection is not initialized"); + } + return db; +}; + +// `checks` is a time-series collection (0002): measurement fields cannot be updated in place, +// so recomputing responseTime requires a backup → drop → recreate → re-insert rewrite. +const createTimeSeriesCollection = async () => { + const db = getDb(); + const existing = await db.listCollections({ name: CHECKS_COLLECTION }).toArray(); + if (existing.length === 0) { + try { + await db.createCollection(CHECKS_COLLECTION, { + timeseries: { + timeField: "createdAt", + metaField: "metadata", + granularity: "seconds", + }, + }); + } catch (error) { + // Mongoose's autoCreate can create `checks` concurrently with this migration + if ((error as { code?: number })?.code !== NAMESPACE_EXISTS_CODE) { + throw error; + } + } + } + + await db + .collection(CHECKS_COLLECTION) + .createIndexes([ + { key: { createdAt: 1 } }, + { key: { "metadata.monitorId": 1, createdAt: 1 } }, + { key: { "metadata.monitorId": 1, createdAt: -1 } }, + { key: { "metadata.teamId": 1, createdAt: -1 } }, + { key: { "metadata.monitorId": 1, "metadata.type": 1, createdAt: -1 } }, + { key: { "metadata.teamId": 1, status: 1, createdAt: -1 } }, + ]); +}; + +const stripId = (doc: Document): Document => { + const { _id, ...rest } = doc; + void _id; + return rest; +}; + +const recomputeResponseTime = (doc: Document): Document => { + const rest = stripId(doc); + const total = doc.timings?.phases?.total; + if (typeof total === "number") { + rest.responseTime = total; + } + return rest; +}; + +const storeFailedDocument = async (doc: Document, reason: string) => { + const db = getDb(); + await db.collection(FAILED_DOCS_COLLECTION).insertOne({ + originalDoc: doc, + reason, + failedAt: new Date(), + }); +}; + +const streamBackupIntoChecks = async (transform: (doc: Document) => Document, collectFailures: boolean): Promise => { + const db = getDb(); + const source = db.collection(BACKUP_COLLECTION); + const target = db.collection(CHECKS_COLLECTION); + + const stats: MigrationStats = { + totalSource: await source.countDocuments(), + totalMigrated: 0, + totalFailed: 0, + }; + + const cursor = source.find(); + // Derive the op type from the exact bulkWrite we call so it matches whichever `mongodb` copy + // Mongoose resolves (see 0002 for the nested-node_modules background) + type BulkOp = Parameters[0][number]; + const operations: BulkOp[] = []; + const invalidDocs: Document[] = []; + + while (await cursor.hasNext()) { + const doc = await cursor.next(); + if (!doc) { + continue; + } + + // Time-series inserts reject documents without the timeField + if (!doc.createdAt) { + invalidDocs.push(doc); + continue; + } + + operations.push({ insertOne: { document: transform(doc) } }); + + if (operations.length >= BATCH_SIZE) { + const result = await target.bulkWrite(operations, { ordered: false }); + stats.totalMigrated += result.insertedCount; + operations.length = 0; + } + } + + if (operations.length) { + const result = await target.bulkWrite(operations, { ordered: false }); + stats.totalMigrated += result.insertedCount; + } + + if (collectFailures) { + for (const doc of invalidDocs) { + await storeFailedDocument(doc, "Missing createdAt (required timeField)"); + stats.totalFailed++; + } + } else { + stats.totalFailed = invalidDocs.length; + } + + await cursor.close(); + + return stats; +}; + +// Restore cannot be a simple rename like 0002's: $out produced a standard collection, and +// renaming it to `checks` would silently lose the time-series type. Rebuild and re-insert instead. +const restoreFromBackup = async () => { + const db = getDb(); + const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray(); + if (backupExists.length === 0) { + throw new Error("Cannot restore: backup collection does not exist"); + } + + const checksExists = await db.listCollections({ name: CHECKS_COLLECTION }).toArray(); + if (checksExists.length > 0) { + await db.collection(CHECKS_COLLECTION).drop(); + } + + await createTimeSeriesCollection(); + await streamBackupIntoChecks(stripId, false); +}; + +export async function recomputeResponseTimeFromTimings(logger: ILogger): Promise { + const db = getDb(); + + const checksExists = await db.listCollections({ name: CHECKS_COLLECTION }).toArray(); + if (checksExists.length === 0) { + logger.info({ service: SERVICE_NAME, message: "No checks collection, nothing to migrate" }); + return; + } + + // Fresh installs and non-HTTP-only deployments have no stored timings — skip the rewrite entirely + const needsMigration = await db.collection(CHECKS_COLLECTION).countDocuments({ "timings.phases.total": { $exists: true } }, { limit: 1 }); + if (needsMigration === 0) { + logger.info({ service: SERVICE_NAME, message: "No checks with timings.phases.total, nothing to migrate" }); + return; + } + + const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray(); + if (backupExists.length > 0) { + throw new Error(`Backup collection "${BACKUP_COLLECTION}" already exists. Please remove it manually before running migration.`); + } + + await db + .collection(CHECKS_COLLECTION) + .aggregate([{ $match: {} }, { $out: BACKUP_COLLECTION }]) + .toArray(); + await db.collection(CHECKS_COLLECTION).drop(); + + try { + await createTimeSeriesCollection(); + const stats = await streamBackupIntoChecks(recomputeResponseTime, true); + logger.info({ + service: SERVICE_NAME, + message: + `Migrated ${stats.totalMigrated}/${stats.totalSource} checks, ${stats.totalFailed} failed ` + + `(failures stored in ${FAILED_DOCS_COLLECTION}); backup retained in ${BACKUP_COLLECTION}`, + }); + } catch (error) { + try { + await restoreFromBackup(); + logger.error({ service: SERVICE_NAME, message: "Migration failed, checks restored from backup" }); + } catch (restoreError) { + const restoreMessage = restoreError instanceof Error ? restoreError.message : String(restoreError); + logger.error({ + service: SERVICE_NAME, + message: `Migration failed AND rollback failed (${restoreMessage}) — original data is preserved in ${BACKUP_COLLECTION}`, + }); + } + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 0b0745d19..f59af32b7 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -7,6 +7,7 @@ import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArra import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js"; +import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -23,6 +24,7 @@ const migrations: MigrationEntry[] = [ { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, { name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt }, + { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, ]; const runMigrations = async (logger: ILogger) => { diff --git a/server/src/service/network/HttpProvider.ts b/server/src/service/network/HttpProvider.ts index b1debd969..fb5f9fea5 100644 --- a/server/src/service/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -117,7 +117,7 @@ export class HttpProvider implements IStatusProvider { if (error instanceof HTTPError || error instanceof RequestError) { const statusCode = error.response?.statusCode; const statusUp = isStatusUp(statusCode, monitor.customUpCodes); - const responseTime = error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0; + const responseTime = error.timings?.phases?.total ?? 0; if (!statusUp) { return { @@ -184,7 +184,7 @@ export class HttpProvider implements IStatusProvider { statusCode: response.statusCode, statusUp, message: response.statusMessage ?? "OK", - responseTime: response.timings.phases.firstByte ?? 0, + responseTime: response.timings.phases.total ?? 0, timings: response.timings, }); } catch (error: unknown) { From 3f088c70f8c043b08b905c1b32c110bd35c3f3b2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 29 Jul 2026 12:30:36 -0700 Subject: [PATCH 11/12] universal ymax cap --- .../monitors/charts/HistogramDetails.tsx | 5 +- .../charts/HistogramDetailsStacked.tsx | 207 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 client/src/Components/monitors/charts/HistogramDetailsStacked.tsx diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index 5c3970d3c..0736abd6c 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -122,7 +122,10 @@ export const HistogramDetails = ({ const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const yMax = useMemo(() => { - const totals = checks.map((check) => check.avgResponseTime || 0); + const totals = checks.map((check) => { + const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0); + return Math.max(check.avgResponseTime ?? 0, phaseSum); + }); return computeYAxisCap(totals); }, [checks]); diff --git a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx new file mode 100644 index 000000000..2ef74f56b --- /dev/null +++ b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx @@ -0,0 +1,207 @@ +import type { GroupedUptimeCheck } from "@/Types/Check"; +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, + Tooltip, +} from "recharts"; +import { BaseBox, BaseChart, Dot } from "@/Components/design-elements"; +import Typography from "@mui/material/Typography"; +import { XTick } from "@/Components/monitors/charts/XTick"; + +import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; +import { useSelector } from "react-redux"; +import { useMemo } from "react"; +import { useTheme } from "@mui/material"; +import { SPACING } from "@/Utils/Theme/constants"; +import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; + +export const PHASE_KEYS = [ + "avgDns", + "avgTcp", + "avgTls", + "avgRequest", + "avgFirstByte", + "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 & { + range: string; + theme: Theme; + uiTimezone: 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 ( + + {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} + Total: {prettyMilliseconds(total)} + {PHASE_KEYS.map((key) => ( + + + + {PHASE_LABELS[key]}:{" "} + {prettyMilliseconds(bucket[key], { + formatSubMilliseconds: true, + compact: true, + })} + + + ))} + + ); +}; + +export const HistogramDetailsStacked = ({ + checks = [], + range, +}: { + checks: GroupedUptimeCheck[]; + range: string; +}) => { + const theme = useTheme(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + + // 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) + ); + return computeYAxisCap(totals); + }, [checks]); + + return ( + + } + title="Timing phases" + > + + + + + {PHASE_KEYS.map((key) => ( + + + + + ))} + + + ( + + )} + /> + ( + + )} + /> + {PHASE_KEYS.map((key) => ( + + ))} + + + + ); +}; From 0172272dc36a1bad7641c8f560d6b937f87df653 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 29 Jul 2026 12:33:56 -0700 Subject: [PATCH 12/12] update tests --- .../test/unit/providers/network/httpProvider.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 88897aca3..9ab69f25d 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -112,7 +112,7 @@ describe("HttpProvider", () => { status: true, code: 200, message: "OK", - responseTime: 100, + responseTime: 120, }) ); }); @@ -182,8 +182,8 @@ describe("HttpProvider", () => { ); }); - it("defaults responseTime to 0 when timings.phases.firstByte is undefined", async () => { - mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { firstByte: undefined } } })); + it("defaults responseTime to 0 when timings.phases.total is undefined", async () => { + mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); @@ -224,11 +224,11 @@ describe("HttpProvider", () => { expect(result.message).toBe("Response is not JSON"); }); - it("defaults responseTime to 0 in non-JSON jsonPath response when firstByte is undefined", async () => { + it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => { mockGot.mockResolvedValue( makeGotResponse({ headers: { "content-type": "text/html" }, - timings: { phases: { firstByte: undefined } }, + timings: { phases: { total: undefined } }, }) ); const { provider } = createProvider();