Merge pull request #3815 from bluewave-labs/feat/stacked-timing-chart

feat: stacked timing chart
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-07-29 12:42:50 -07:00
commit 23b420d072
22 files changed
+749 -136

No files matched your search

@@ -13,6 +13,7 @@ type BaseChartProps = React.PropsWithChildren<{
width?: number | string;
maxWidth?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>;
export const BaseChart = ({
@@ -21,16 +22,22 @@ export const BaseChart = ({
width = "100%",
maxWidth = "100%",
padding,
onClick,
}: BaseChartProps) => {
const theme = useTheme();
return (
<BaseBox
onClick={onClick}
sx={{
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
width: width,
maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}}
>
<Stack
@@ -0,0 +1,33 @@
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;
};
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",
};
@@ -1,53 +1,39 @@
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,
XAxis,
YAxis,
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";
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
import { useMemo, useState } from "react";
import { SPACING } from "@/Utils/Theme/constants";
import {
PHASE_COLOR_KEYS,
PHASE_KEYS,
PHASE_LABELS,
} from "@/Components/monitors/charts/ChartUtils";
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>
);
};
type DetailsCheck = GroupedCheck &
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
@@ -68,12 +54,59 @@ const ResponseTimeToolTip = ({
if (!active) return null;
const format = tooltipDateFormatLookup(range);
console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 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>
);
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
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 (
<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>
);
};
@@ -82,11 +115,25 @@ 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) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return (
<BaseChart
icon={
@@ -95,7 +142,11 @@ export const HistogramDetails = ({
strokeWidth={1.5}
/>
}
title="Response times"
title={showTimingPhases ? "Timing Phases" : "Response Time"}
onClick={() => {
if (!hasTimingPhases) return;
setShowTimingPhases((prev) => !prev);
}}
>
<ResponsiveContainer
width="100%"
@@ -128,7 +179,34 @@ export const HistogramDetails = ({
stopOpacity={0}
/>
</linearGradient>
{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}
@@ -141,6 +219,18 @@ export const HistogramDetails = ({
)}
/>
{showTimingPhases ? (
<Tooltip
content={(props) => (
<TimingPhasesToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
) : (
<Tooltip
content={(props) => (
<ResponseTimeToolTip
@@ -151,12 +241,29 @@ export const HistogramDetails = ({
/>
)}
/>
)}
{!showTimingPhases && (
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
)}
{hasTimingPhases &&
showTimingPhases &&
PHASE_KEYS.map((key) => {
return (
<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>
@@ -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<ValueType, NameType> & {
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 (
<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 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 (
<BaseChart
icon={
<Clock
size={20}
strokeWidth={1.5}
/>
}
title="Timing phases"
>
<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>
);
};
@@ -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";
@@ -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,
@@ -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 (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
+11 -3
View File
@@ -57,7 +57,6 @@ const UptimeDetailsPage = () => {
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
params.append("normalize", "true");
return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
}, [monitorId, dateRange]);
@@ -72,6 +71,7 @@ const UptimeDetailsPage = () => {
);
const monitorData = monitorDetailsData?.monitorData;
const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
@@ -165,10 +165,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;
@@ -215,7 +223,7 @@ const UptimeDetailsPage = () => {
/>
</Stack>
<HistogramDetails
checks={monitorData?.groupedChecks || []}
checks={monitorData.groupedChecks}
range={dateRange}
/>
<ChecksTable
+9 -4
View File
@@ -149,6 +149,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;
@@ -252,7 +261,3 @@ export interface HasResponseTime {
export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
originalResponseTime: number;
};
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
+2 -2
View File
@@ -1,4 +1,4 @@
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
import type { GroupedCheck, CheckSnapshot, GroupedUptimeCheck } from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
@@ -137,7 +137,7 @@ export interface MonitorStats {
export interface MonitorData {
monitor: Monitor;
groupedChecks: GroupedCheck[];
groupedChecks: GroupedUptimeCheck[];
groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[];
groupedAvgResponseTime: number;
+31
View File
@@ -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,
},
},
};
+20
View File
@@ -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;
};
};
}
}
@@ -197,7 +197,6 @@ export const getUptimeDetailsByIdParamValidation = z.object({
export const getUptimeDetailsByIdQueryValidation = z.object({
dateRange: z.enum(DateRanges),
normalize: booleanCoercion.optional(),
});
const importedMonitorSchema = z
@@ -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<MigrationStats> => {
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<typeof target.bulkWrite>[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<void> {
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;
}
}
+2
View File
@@ -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) => {
@@ -445,11 +445,30 @@ class MongoChecksRepository implements IChecksRepository {
$dateToString: { format: dateString, date: "$createdAt" },
},
avgResponseTime: { $avg: "$responseTime" },
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 },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
{
$project: {
bucketDate: "$_id",
avgResponseTime: 1,
totalChecks: 1,
avgDns: 1,
avgTcp: 1,
avgTls: 1,
avgRequest: 1,
avgFirstByte: 1,
avgDownload: 1,
_id: 0,
},
},
],
groupedUpChecks: [
{ $match: { status: true } },
+10 -5
View File
@@ -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<MonitorType, "hardware" | "pagespeed">;
groupedChecks: GroupedCheck[];
groupedChecks: GroupedUptimeCheck[];
groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[];
uptimePercentage: number;
@@ -163,10 +172,6 @@ export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
originalResponseTime: number;
};
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
export type CheckSnapshot = Omit<Check, "metadata" | "updatedAt">;
export interface HardwareDiskStats {
@@ -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,
},
+2 -2
View File
@@ -117,7 +117,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
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<HttpStatusPayload> {
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) {
+1 -15
View File
@@ -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 = <T extends HasResponseTime>(checks: T[], rangeMin:
originalResponseTime: original(check),
}));
};
export const NormalizeDataUptimeDetails = <T extends GroupedCheck>(checks: T[], rangeMin: number, rangeMax: number): NormalizedUptimeCheck<T>[] => {
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),
}));
};
@@ -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();
+1 -56
View File
@@ -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<string, unknown>) =>
...overrides,
});
const makeGrouped = (avgResponseTime: number, overrides?: Partial<GroupedCheck>): 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);
});
});
});