Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81fdb9810 | ||
|
|
9712579c4d | ||
|
|
98e0ffc983 | ||
|
|
ac251c4d3f | ||
|
|
08f167c18e | ||
|
|
8a608961e6 | ||
|
|
093553f9ba | ||
|
|
e5282b9099 | ||
|
|
6b4444038c | ||
|
|
89464e9be4 | ||
|
|
6856914d41 | ||
|
|
c102cd8d26 | ||
|
|
ff28e74aca | ||
|
|
23b420d072 | ||
|
|
0172272dc3 | ||
|
|
3f088c70f8 | ||
|
|
ed54fa4668 | ||
|
|
0e0cace676 | ||
|
|
58f1ae7fe9 | ||
|
|
e3075318b6 | ||
|
|
81aab35961 | ||
|
|
4e495eed33 | ||
|
|
061da5e7ef | ||
|
|
c7662727e7 | ||
|
|
13c921531c | ||
|
|
0cf8cfa5dc | ||
|
|
04f7ef252a | ||
|
|
3201653f19 | ||
|
|
0988a61c1d |
No files matched your search
@@ -7,6 +7,7 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
|
|
||||||
type HeatmapCheck =
|
type HeatmapCheck =
|
||||||
| CheckSnapshot
|
| CheckSnapshot
|
||||||
@@ -58,7 +59,9 @@ export const HeatmapResponseTimeTooltip = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography>
|
<Typography>
|
||||||
{t("common.labels.responseTime")}:{" "}
|
{t("common.labels.responseTime")}:{" "}
|
||||||
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
|
{check?.responseTime
|
||||||
|
? prettyMilliseconds(check.responseTime, { compact: true })
|
||||||
|
: "N/A"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography textTransform={"capitalize"}>
|
<Typography textTransform={"capitalize"}>
|
||||||
Status:{" "}
|
Status:{" "}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
import { computeBarHeights } from "@/Utils/DataUtils";
|
||||||
|
|
||||||
interface HistogramResponseTimeProps {
|
interface HistogramResponseTimeProps {
|
||||||
checks: CheckSnapshot[];
|
checks: CheckSnapshot[];
|
||||||
@@ -29,12 +30,12 @@ const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats
|
|||||||
return { max: "-", avg: "-" };
|
return { max: "-", avg: "-" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validChecks = checks.filter((check) => check.originalResponseTime != null);
|
const validChecks = checks.filter((check) => check.responseTime != null);
|
||||||
if (validChecks.length === 0) {
|
if (validChecks.length === 0) {
|
||||||
return { max: "-", avg: "-" };
|
return { max: "-", avg: "-" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseTimes = validChecks.map((check) => check.originalResponseTime);
|
const responseTimes = validChecks.map((check) => check.responseTime);
|
||||||
const max = Math.round(Math.max(...responseTimes));
|
const max = Math.round(Math.max(...responseTimes));
|
||||||
const avg = Math.round(
|
const avg = Math.round(
|
||||||
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
||||||
@@ -54,7 +55,7 @@ export const HistogramResponseTime = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
||||||
|
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
|
||||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
@@ -111,7 +112,7 @@ export const HistogramResponseTime = ({
|
|||||||
>
|
>
|
||||||
{data.map((check, index) => {
|
{data.map((check, index) => {
|
||||||
const isPlaceholder = (check as any).status === "placeholder";
|
const isPlaceholder = (check as any).status === "placeholder";
|
||||||
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
|
const heightPct = `${barHeights[index] ?? 0}%`;
|
||||||
const barColor =
|
const barColor =
|
||||||
check.status === true
|
check.status === true
|
||||||
? theme.palette.success.light
|
? theme.palette.success.light
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type BaseChartProps = React.PropsWithChildren<{
|
|||||||
width?: number | string;
|
width?: number | string;
|
||||||
maxWidth?: number | string;
|
maxWidth?: number | string;
|
||||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||||
|
onClick?: () => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const BaseChart = ({
|
export const BaseChart = ({
|
||||||
@@ -21,16 +22,22 @@ export const BaseChart = ({
|
|||||||
width = "100%",
|
width = "100%",
|
||||||
maxWidth = "100%",
|
maxWidth = "100%",
|
||||||
padding,
|
padding,
|
||||||
|
onClick,
|
||||||
}: BaseChartProps) => {
|
}: BaseChartProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<BaseBox
|
<BaseBox
|
||||||
|
onClick={onClick}
|
||||||
sx={{
|
sx={{
|
||||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: width,
|
width: width,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
|
...(onClick && {
|
||||||
|
cursor: "pointer",
|
||||||
|
"& .recharts-wrapper": { cursor: "pointer !important" },
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
|
||||||
|
export type DotSize = (typeof DotSizes)[number];
|
||||||
|
|
||||||
|
const DOT_SIZE_PX: Record<DotSize, string> = {
|
||||||
|
xs: "2px",
|
||||||
|
sm: "4px",
|
||||||
|
md: "8px",
|
||||||
|
lg: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
interface DotProps {
|
interface DotProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: string;
|
size?: DotSize;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
|
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
content: '""',
|
content: '""',
|
||||||
width: size,
|
width: DOT_SIZE_PX[size],
|
||||||
height: size,
|
height: DOT_SIZE_PX[size],
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// i18n keys, resolved with t() at render
|
||||||
|
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
|
||||||
|
avgDns: "common.charts.phases.dns",
|
||||||
|
avgTcp: "common.charts.phases.tcp",
|
||||||
|
avgTls: "common.charts.phases.tls",
|
||||||
|
avgRequest: "common.charts.phases.request",
|
||||||
|
avgFirstByte: "common.charts.phases.firstByte",
|
||||||
|
avgDownload: "common.charts.phases.download",
|
||||||
|
};
|
||||||
@@ -1,53 +1,40 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
XAxis,
|
XAxis,
|
||||||
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Text,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { TooltipProps } from "recharts";
|
import type { TooltipProps } from "recharts";
|
||||||
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
|
import { XTick } from "@/Components/monitors/charts/XTick";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import {
|
import { useTranslation } from "react-i18next";
|
||||||
formatDateWithTz,
|
import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
|
||||||
tickDateFormatLookup,
|
|
||||||
tooltipDateFormatLookup,
|
|
||||||
} from "@/Utils/TimeUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { Theme } 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 type { RootState } from "@/Types/state";
|
||||||
|
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
|
||||||
|
|
||||||
type XTickProps = {
|
import { useMemo, useState } from "react";
|
||||||
x: number;
|
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||||
y: number;
|
import {
|
||||||
payload: { value: string };
|
PHASE_COLOR_KEYS,
|
||||||
range: string;
|
PHASE_KEYS,
|
||||||
};
|
PHASE_LABEL_KEYS,
|
||||||
|
} from "@/Components/monitors/charts/ChartUtils";
|
||||||
|
|
||||||
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
type DetailsCheck = GroupedCheck &
|
||||||
const format = tickDateFormatLookup(range);
|
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
|
||||||
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 ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
range: string;
|
range: string;
|
||||||
@@ -63,17 +50,72 @@ const ResponseTimeToolTip = ({
|
|||||||
theme,
|
theme,
|
||||||
uiTimezone,
|
uiTimezone,
|
||||||
}: ResponseTimeToolTipProps) => {
|
}: ResponseTimeToolTipProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
|
|
||||||
const format = tooltipDateFormatLookup(range);
|
const format = tooltipDateFormatLookup(range);
|
||||||
console.log(payload);
|
const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0);
|
||||||
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
|
|
||||||
return (
|
return (
|
||||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
||||||
<Typography>Response time: {responseTime} ms</Typography>
|
<Typography>
|
||||||
|
{t("common.charts.histogram.responseTime", {
|
||||||
|
value: 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) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
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>
|
||||||
|
{t("common.charts.histogram.total", { value: prettyMilliseconds(total) })}
|
||||||
|
</Typography>
|
||||||
|
{PHASE_KEYS.map((key) => (
|
||||||
|
<Stack
|
||||||
|
alignItems={"center"}
|
||||||
|
direction="row"
|
||||||
|
key={key}
|
||||||
|
gap={SPACING.LG}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
size="lg"
|
||||||
|
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
||||||
|
/>
|
||||||
|
<Typography>
|
||||||
|
{t(PHASE_LABEL_KEYS[key])}:{" "}
|
||||||
|
{prettyMilliseconds(bucket[key], {
|
||||||
|
formatSubMilliseconds: true,
|
||||||
|
compact: true,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
</BaseBox>
|
</BaseBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -82,11 +124,28 @@ export const HistogramDetails = ({
|
|||||||
checks,
|
checks,
|
||||||
range,
|
range,
|
||||||
}: {
|
}: {
|
||||||
checks: GroupedCheck[];
|
checks: DetailsCheck[];
|
||||||
range: string;
|
range: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const [showTimingPhases, setShowTimingPhases] = useState(false);
|
||||||
|
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
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 (
|
return (
|
||||||
<BaseChart
|
<BaseChart
|
||||||
icon={
|
icon={
|
||||||
@@ -95,7 +154,15 @@ export const HistogramDetails = ({
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title="Response times"
|
title={
|
||||||
|
showTimingPhases
|
||||||
|
? t("common.charts.labels.timingPhases")
|
||||||
|
: t("common.charts.labels.responseTime")
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasTimingPhases) return;
|
||||||
|
setShowTimingPhases((prev) => !prev);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ResponsiveContainer
|
<ResponsiveContainer
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -128,7 +195,34 @@ export const HistogramDetails = ({
|
|||||||
stopOpacity={0}
|
stopOpacity={0}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</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>
|
</defs>
|
||||||
|
<YAxis
|
||||||
|
hide
|
||||||
|
// scale="sqrt"
|
||||||
|
domain={[0, yMax ?? "auto"]}
|
||||||
|
allowDataOverflow
|
||||||
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -141,24 +235,99 @@ export const HistogramDetails = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
{showTimingPhases ? (
|
||||||
content={(props) => (
|
<Tooltip
|
||||||
<ResponseTimeToolTip
|
content={(props) => (
|
||||||
{...props}
|
<TimingPhasesToolTip
|
||||||
range={range}
|
{...props}
|
||||||
theme={theme}
|
range={range}
|
||||||
uiTimezone={uiTimezone}
|
theme={theme}
|
||||||
/>
|
uiTimezone={uiTimezone}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
<Area
|
/>
|
||||||
type="monotone"
|
) : (
|
||||||
dataKey="avgResponseTime"
|
<Tooltip
|
||||||
stroke={theme.palette.primary.main}
|
content={(props) => (
|
||||||
fill="url(#colorUv)"
|
<ResponseTimeToolTip
|
||||||
/>
|
{...props}
|
||||||
|
range={range}
|
||||||
|
theme={theme}
|
||||||
|
uiTimezone={uiTimezone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!showTimingPhases && (
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avgResponseTime"
|
||||||
|
stroke={theme.palette.primary.main}
|
||||||
|
fill="url(#colorUv)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasTimingPhases &&
|
||||||
|
showTimingPhases &&
|
||||||
|
PHASE_KEYS.map((key) => {
|
||||||
|
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
stackId={1}
|
||||||
|
dataKey={key}
|
||||||
|
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
||||||
|
fill={`url(#phaseGradient-${key})`}
|
||||||
|
fillOpacity={dimmed ? 0.2 : undefined}
|
||||||
|
strokeOpacity={dimmed ? 0.2 : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
<Stack
|
||||||
|
justifyContent={"center"}
|
||||||
|
gap={LAYOUT.XS}
|
||||||
|
direction={"row"}
|
||||||
|
flexWrap={"wrap"}
|
||||||
|
>
|
||||||
|
{showTimingPhases ? (
|
||||||
|
PHASE_KEYS.map((key) => {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={SPACING.LG}
|
||||||
|
key={key}
|
||||||
|
onMouseEnter={() => setHoveredPhase(key)}
|
||||||
|
onMouseLeave={() => setHoveredPhase(null)}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={SPACING.LG}
|
||||||
|
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
|
||||||
|
onMouseLeave={() => setHoveredPhase(null)}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
color={theme.palette.primary.main}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<Typography lineHeight={1}>
|
||||||
|
{t("common.charts.labels.totalResponseTime")}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</BaseChart>
|
</BaseChart>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Fragment, useId } from "react";
|
import { Fragment, useId } from "react";
|
||||||
import { XTick } from "@/Components/monitors";
|
import { XTick } from "@/Components/monitors/charts/XTick";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { HardwareCheckStats } from "@/Types/Monitor";
|
import type { HardwareCheckStats } from "@/Types/Monitor";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
import { BaseChart } from "@/Components/design-elements";
|
||||||
import { TrendingUp } from "lucide-react";
|
import { TrendingUp } from "lucide-react";
|
||||||
import { XTick } from "@/Components/monitors";
|
import { XTick } from "@/Components/monitors/charts/XTick";
|
||||||
import {
|
import {
|
||||||
XAxis,
|
XAxis,
|
||||||
AreaChart,
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,13 +13,12 @@ const generateDummyChecks = (): CheckSnapshot[] => {
|
|||||||
const checks: CheckSnapshot[] = [];
|
const checks: CheckSnapshot[] = [];
|
||||||
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
|
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
|
||||||
const isUp = Math.random() > 0.1;
|
const isUp = Math.random() > 0.1;
|
||||||
const responseTime = Math.floor(Math.random() * 80) + 20;
|
const responseTime = Math.floor(Math.random() * 800) + 20;
|
||||||
checks.push({
|
checks.push({
|
||||||
id: `dummy-${i}`,
|
id: `dummy-${i}`,
|
||||||
status: isUp,
|
status: isUp,
|
||||||
statusCode: isUp ? 200 : 500,
|
statusCode: isUp ? 200 : 500,
|
||||||
responseTime: responseTime,
|
responseTime: responseTime,
|
||||||
originalResponseTime: responseTime,
|
|
||||||
message: "",
|
message: "",
|
||||||
createdAt: new Date(Date.now() - i * 60000).toISOString(),
|
createdAt: new Date(Date.now() - i * 60000).toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
type StatusPageMonitor = Monitor;
|
||||||
|
|
||||||
export interface BaseStyles {
|
export interface BaseStyles {
|
||||||
page: SxProps<Theme>;
|
page: SxProps<Theme>;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { timezone } = useStatusPageTheme();
|
||||||
|
return (
|
||||||
|
<Stack gap={0.25}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{check.status
|
||||||
|
? `${prettyMilliseconds(check.responseTime, { compact: true })}`
|
||||||
|
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
|
||||||
|
|
||||||
const CELLS = MAX_RECENT_CHECKS;
|
const CELLS = MAX_RECENT_CHECKS;
|
||||||
|
|
||||||
@@ -29,7 +26,6 @@ const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
|
|||||||
|
|
||||||
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { timezone } = useStatusPageTheme();
|
|
||||||
|
|
||||||
const source = checks.slice(-CELLS);
|
const source = checks.slice(-CELLS);
|
||||||
const padded: (CheckSnapshot | null)[] = [
|
const padded: (CheckSnapshot | null)[] = [
|
||||||
@@ -53,24 +49,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const kind = classify(check);
|
const kind = classify(check);
|
||||||
const tooltipContent = (
|
const tooltipContent = <ThemedChartTooltip check={check} />;
|
||||||
<Stack gap={0.25}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
fontWeight={600}
|
|
||||||
>
|
|
||||||
{check.status
|
|
||||||
? `${check.responseTime} ms`
|
|
||||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ opacity: 0.8 }}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={check.id ?? i}
|
key={check.id ?? i}
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import { useMemo } from "react";
|
|||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
import { computeBarHeights } from "@/Utils/DataUtils";
|
||||||
|
import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
|
||||||
const CELLS = MAX_RECENT_CHECKS;
|
const CELLS = MAX_RECENT_CHECKS;
|
||||||
const MIN_HEIGHT_PCT = 6;
|
const MIN_HEIGHT_PCT = 6;
|
||||||
|
|
||||||
@@ -33,20 +32,20 @@ export const ThemedHistogram = ({
|
|||||||
statsGap = 1,
|
statsGap = 1,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { timezone } = useStatusPageTheme();
|
|
||||||
|
|
||||||
const { padded, max, avg, peak } = useMemo(() => {
|
const { padded, heights, avg, peak } = useMemo(() => {
|
||||||
const source = checks.slice(-CELLS);
|
const source = checks.slice(-CELLS);
|
||||||
const out: (CheckSnapshot | null)[] = [
|
const out: (CheckSnapshot | null)[] = [
|
||||||
...source,
|
...source,
|
||||||
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
|
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
|
||||||
];
|
];
|
||||||
|
const heights = computeBarHeights(source);
|
||||||
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
|
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
|
||||||
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
|
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
|
||||||
const avgRt = valid.length
|
const avgRt = valid.length
|
||||||
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
|
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
|
||||||
: 0;
|
: 0;
|
||||||
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
|
return { padded: out, heights, avg: avgRt, peak: maxRt };
|
||||||
}, [checks]);
|
}, [checks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,32 +60,8 @@ export const ThemedHistogram = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const height = Math.max(
|
const height = heights[i] ?? MIN_HEIGHT_PCT;
|
||||||
MIN_HEIGHT_PCT,
|
const tooltipContent = <ThemedChartTooltip check={check} />;
|
||||||
Math.round((check.responseTime / max) * 100)
|
|
||||||
);
|
|
||||||
const tooltipContent = (
|
|
||||||
<Stack gap={0.25}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
fontWeight={600}
|
|
||||||
>
|
|
||||||
{check.status
|
|
||||||
? `${check.responseTime} ms`
|
|
||||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ opacity: 0.8 }}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(
|
|
||||||
check.createdAt,
|
|
||||||
"ddd, MMMM D, YYYY, HH:mm A",
|
|
||||||
timezone
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={check.id ?? i}
|
key={check.id ?? i}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface InfraSx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
monitor: Monitor & { checks?: Monitor["recentChecks"] };
|
monitor: Monitor;
|
||||||
sxApi: InfraSx;
|
sxApi: InfraSx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ const heatLevel = (value: number): GaugeFillLevel =>
|
|||||||
|
|
||||||
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
|
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
|
const latest = monitor.recentChecks?.[0];
|
||||||
|
|
||||||
const renderEmpty = () => (
|
const renderEmpty = () => (
|
||||||
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
|
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ const UptimeDetailsPage = () => {
|
|||||||
}
|
}
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append("dateRange", dateRange);
|
params.append("dateRange", dateRange);
|
||||||
params.append("normalize", "true");
|
|
||||||
return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
|
return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
|
||||||
}, [monitorId, dateRange]);
|
}, [monitorId, dateRange]);
|
||||||
|
|
||||||
@@ -72,6 +71,7 @@ const UptimeDetailsPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const monitorData = monitorDetailsData?.monitorData;
|
const monitorData = monitorDetailsData?.monitorData;
|
||||||
|
|
||||||
const monitor = monitorData?.monitor;
|
const monitor = monitorData?.monitor;
|
||||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||||
|
|
||||||
@@ -165,10 +165,18 @@ const UptimeDetailsPage = () => {
|
|||||||
{ keepPreviousData: true, revalidateOnFocus: false }
|
{ keepPreviousData: true, revalidateOnFocus: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!monitorData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!monitor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
|
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
|
||||||
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
|
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
|
||||||
|
|
||||||
const geoLocations = monitor?.geoCheckLocations;
|
const geoLocations = monitor.geoCheckLocations;
|
||||||
|
|
||||||
const checks = checksData?.checks ?? [];
|
const checks = checksData?.checks ?? [];
|
||||||
const checksCount = checksData?.checksCount ?? 0;
|
const checksCount = checksData?.checksCount ?? 0;
|
||||||
@@ -215,7 +223,7 @@ const UptimeDetailsPage = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<HistogramDetails
|
<HistogramDetails
|
||||||
checks={monitorData?.groupedChecks || []}
|
checks={monitorData.groupedChecks}
|
||||||
range={dateRange}
|
range={dateRange}
|
||||||
/>
|
/>
|
||||||
<ChecksTable
|
<ChecksTable
|
||||||
|
|||||||
+10
-12
@@ -149,6 +149,15 @@ export interface GroupedCheck {
|
|||||||
totalChecks: number;
|
totalChecks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupedUptimeCheck extends GroupedCheck {
|
||||||
|
avgDns: number;
|
||||||
|
avgTcp: number;
|
||||||
|
avgTls: number;
|
||||||
|
avgRequest: number;
|
||||||
|
avgFirstByte: number;
|
||||||
|
avgDownload: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageSpeedGroupedCheck {
|
export interface PageSpeedGroupedCheck {
|
||||||
bucketDate: string;
|
bucketDate: string;
|
||||||
performance: number;
|
performance: number;
|
||||||
@@ -241,18 +250,7 @@ export interface ChecksSummary {
|
|||||||
downChecks: number;
|
downChecks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
|
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt">;
|
||||||
originalResponseTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface HasResponseTime {
|
export interface HasResponseTime {
|
||||||
responseTime: number;
|
responseTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
|
|
||||||
originalResponseTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
|
|
||||||
originalAvgResponseTime: number;
|
|
||||||
};
|
|
||||||
@@ -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 { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||||
export type { GeoContinent } from "@/Types/GeoCheck";
|
export type { GeoContinent } from "@/Types/GeoCheck";
|
||||||
@@ -137,7 +137,7 @@ export interface MonitorStats {
|
|||||||
|
|
||||||
export interface MonitorData {
|
export interface MonitorData {
|
||||||
monitor: Monitor;
|
monitor: Monitor;
|
||||||
groupedChecks: GroupedCheck[];
|
groupedChecks: GroupedUptimeCheck[];
|
||||||
groupedUpChecks: GroupedCheck[];
|
groupedUpChecks: GroupedCheck[];
|
||||||
groupedDownChecks: GroupedCheck[];
|
groupedDownChecks: GroupedCheck[];
|
||||||
groupedAvgResponseTime: number;
|
groupedAvgResponseTime: number;
|
||||||
|
|||||||
@@ -1,47 +1,3 @@
|
|||||||
const MIN_OUT = 10;
|
|
||||||
const MAX_OUT = 100;
|
|
||||||
|
|
||||||
export const normalizeResponseTimes = <T, K extends keyof T>(
|
|
||||||
checks: T[],
|
|
||||||
key: K
|
|
||||||
): (T & { normalResponseTime: number })[] => {
|
|
||||||
if (!Array.isArray(checks) || checks.length === 0)
|
|
||||||
return checks as (T & {
|
|
||||||
normalResponseTime: number;
|
|
||||||
})[];
|
|
||||||
|
|
||||||
if (checks.length === 1) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...checks[0],
|
|
||||||
normalResponseTime: 50,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const getVal = (check: T): number => {
|
|
||||||
const v = check[key] as unknown;
|
|
||||||
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { min, max } = checks.reduce(
|
|
||||||
(acc, check) => {
|
|
||||||
const v = getVal(check);
|
|
||||||
if (v > acc.max) acc.max = v;
|
|
||||||
if (v < acc.min) acc.min = v;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ max: -Infinity, min: Infinity }
|
|
||||||
);
|
|
||||||
|
|
||||||
const range = max - min || 1;
|
|
||||||
|
|
||||||
return checks.map((check) => ({
|
|
||||||
...check,
|
|
||||||
normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Interpolate color between three theme colors over 0-100 range.
|
// Interpolate color between three theme colors over 0-100 range.
|
||||||
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
|
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
|
||||||
export const getResponseColor = (
|
export const getResponseColor = (
|
||||||
@@ -129,3 +85,32 @@ export const getResponseColor = (
|
|||||||
}
|
}
|
||||||
return normalizeToHex(safe.end);
|
return normalizeToHex(safe.end);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MIN_HEIGHT_PCT = 8;
|
||||||
|
const CAP_PERCENTILE = 95;
|
||||||
|
|
||||||
|
const percentile = (sortedValues: number[], p: number): number => {
|
||||||
|
const index = (p / 100) * (sortedValues.length - 1);
|
||||||
|
const lower = Math.floor(index);
|
||||||
|
const upper = lower + 1;
|
||||||
|
const weight = index % 1;
|
||||||
|
if (upper >= sortedValues.length) return sortedValues[lower];
|
||||||
|
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeBarHeights = (
|
||||||
|
checks: { responseTime?: number | null }[]
|
||||||
|
): number[] => {
|
||||||
|
const values = checks.map((check) => {
|
||||||
|
return typeof check.responseTime === "number" && Number.isFinite(check.responseTime)
|
||||||
|
? Math.max(0, check.responseTime)
|
||||||
|
: 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = values.slice().sort((a, b) => a - b);
|
||||||
|
const cap = percentile(sorted, CAP_PERCENTILE);
|
||||||
|
if (cap <= 0) return values.map(() => MIN_HEIGHT_PCT);
|
||||||
|
return values.map((value) => {
|
||||||
|
return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -20,8 +20,19 @@ export const colors = {
|
|||||||
brandGreen: "#13715B",
|
brandGreen: "#13715B",
|
||||||
brandGreenLight: "#4DAF94",
|
brandGreenLight: "#4DAF94",
|
||||||
green200: "#ECF7F2",
|
green200: "#ECF7F2",
|
||||||
|
green700: "#008300",
|
||||||
yellow200: "#FFF4E5",
|
yellow200: "#FFF4E5",
|
||||||
|
yellow500: "#EDA100",
|
||||||
|
yellow600: "#C98500",
|
||||||
orange500: "#FFA500",
|
orange500: "#FFA500",
|
||||||
|
orange600: "#EB6834",
|
||||||
|
orange700: "#D95926",
|
||||||
|
blue400: "#3987E5",
|
||||||
|
blue500: "#2A78D6",
|
||||||
|
aqua500: "#1BAF7A",
|
||||||
|
aqua600: "#199E70",
|
||||||
|
magenta300: "#E87BA4",
|
||||||
|
magenta400: "#D55181",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const lightPalette = {
|
export const lightPalette = {
|
||||||
@@ -38,6 +49,16 @@ export const lightPalette = {
|
|||||||
running: colors.green200,
|
running: colors.green200,
|
||||||
paused: colors.yellow200,
|
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 = {
|
export const darkPalette = {
|
||||||
@@ -58,4 +79,14 @@ export const darkPalette = {
|
|||||||
running: alpha(colors.brandGreen, 0.18),
|
running: alpha(colors.brandGreen, 0.18),
|
||||||
paused: alpha(colors.orange500, 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -16,10 +16,30 @@ declare module "@mui/material/styles" {
|
|||||||
interface Palette {
|
interface Palette {
|
||||||
sidebar: { accent: string };
|
sidebar: { accent: string };
|
||||||
rowStatus: { running: string; paused: string };
|
rowStatus: { running: string; paused: string };
|
||||||
|
chart: {
|
||||||
|
phases: {
|
||||||
|
dns: string;
|
||||||
|
tcp: string;
|
||||||
|
tls: string;
|
||||||
|
request: string;
|
||||||
|
firstByte: string;
|
||||||
|
download: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
interface PaletteOptions {
|
interface PaletteOptions {
|
||||||
sidebar?: { accent: string };
|
sidebar?: { accent: string };
|
||||||
rowStatus?: { running: string; paused: string };
|
rowStatus?: { running: string; paused: string };
|
||||||
|
chart?: {
|
||||||
|
phases: {
|
||||||
|
dns: string;
|
||||||
|
tcp: string;
|
||||||
|
tls: string;
|
||||||
|
request: string;
|
||||||
|
firstByte: string;
|
||||||
|
download: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,24 @@
|
|||||||
"downtime": "Downtime",
|
"downtime": "Downtime",
|
||||||
"high": "High",
|
"high": "High",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
|
"responseTime": "Response time",
|
||||||
|
"timingPhases": "Timing phases",
|
||||||
|
"totalResponseTime": "Total response time",
|
||||||
"uptime": "Uptime"
|
"uptime": "Uptime"
|
||||||
},
|
},
|
||||||
"histogram": {
|
"histogram": {
|
||||||
"avg": "Avg: {{value}} ms",
|
"avg": "Avg: {{value}} ms",
|
||||||
"max": "Max: {{value}} ms"
|
"max": "Max: {{value}} ms",
|
||||||
|
"responseTime": "Response time: {{value}}",
|
||||||
|
"total": "Total: {{value}}"
|
||||||
|
},
|
||||||
|
"phases": {
|
||||||
|
"dns": "DNS",
|
||||||
|
"tcp": "TCP",
|
||||||
|
"tls": "TLS",
|
||||||
|
"request": "Request",
|
||||||
|
"firstByte": "First byte",
|
||||||
|
"download": "Download"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
|
|||||||
+343
-13
@@ -536,6 +536,341 @@
|
|||||||
"updatedAt": "2026-04-15T14:30:00.000Z"
|
"updatedAt": "2026-04-15T14:30:00.000Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"UptimeDetails": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"monitorData": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"monitor": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"isActive": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"interval": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["up", "down", "paused", "initializing", "maintenance", "breached"]
|
||||||
|
},
|
||||||
|
"statusWindowSize": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"statusWindowThreshold": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"ignoreTlsErrors": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"useAdvancedMatching": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"jsonPath": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expectedValue": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"matchMethod": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["equal", "include", "regex"]
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["GET", "HEAD"]
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customUpCodes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"cpuAlertThreshold": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"memoryAlertThreshold": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"diskAlertThreshold": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"tempAlertThreshold": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"selectedDisks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gameId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"grpcServiceName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"geoCheckEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"geoCheckLocations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["EU", "NA", "AS", "SA", "AF", "OC"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geoCheckInterval": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"dnsServer": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dnsRecordType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
|
||||||
|
},
|
||||||
|
"teamId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lastEvaluatedAt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"url",
|
||||||
|
"isActive",
|
||||||
|
"interval",
|
||||||
|
"status",
|
||||||
|
"statusWindowSize",
|
||||||
|
"statusWindowThreshold",
|
||||||
|
"ignoreTlsErrors",
|
||||||
|
"useAdvancedMatching",
|
||||||
|
"method",
|
||||||
|
"notifications",
|
||||||
|
"tags",
|
||||||
|
"cpuAlertThreshold",
|
||||||
|
"memoryAlertThreshold",
|
||||||
|
"diskAlertThreshold",
|
||||||
|
"tempAlertThreshold",
|
||||||
|
"selectedDisks",
|
||||||
|
"geoCheckEnabled",
|
||||||
|
"geoCheckLocations",
|
||||||
|
"geoCheckInterval",
|
||||||
|
"teamId",
|
||||||
|
"userId",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"lastEvaluatedAt",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"additionalProperties": {
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groupedChecks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bucketDate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avgResponseTime": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"totalChecks": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"avgDns": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"avgTcp": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"avgTls": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"avgRequest": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"avgFirstByte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"avgDownload": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"bucketDate",
|
||||||
|
"avgResponseTime",
|
||||||
|
"totalChecks",
|
||||||
|
"avgDns",
|
||||||
|
"avgTcp",
|
||||||
|
"avgTls",
|
||||||
|
"avgRequest",
|
||||||
|
"avgFirstByte",
|
||||||
|
"avgDownload"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groupedUpChecks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bucketDate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avgResponseTime": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"totalChecks": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["bucketDate", "avgResponseTime", "totalChecks"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groupedDownChecks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bucketDate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avgResponseTime": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"totalChecks": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["bucketDate", "avgResponseTime", "totalChecks"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groupedAvgResponseTime": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"groupedUptimePercentage": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["monitor", "groupedChecks", "groupedUpChecks", "groupedDownChecks", "groupedAvgResponseTime", "groupedUptimePercentage"]
|
||||||
|
},
|
||||||
|
"monitorStats": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"monitorId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avgResponseTime": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"maxResponseTime": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"totalChecks": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"totalUpChecks": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"totalDownChecks": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"uptimePercentage": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lastCheckTimestamp": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lastResponseTime": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"timeOfLastFailure": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"monitorId",
|
||||||
|
"avgResponseTime",
|
||||||
|
"maxResponseTime",
|
||||||
|
"totalChecks",
|
||||||
|
"totalUpChecks",
|
||||||
|
"totalDownChecks",
|
||||||
|
"uptimePercentage",
|
||||||
|
"lastCheckTimestamp",
|
||||||
|
"lastResponseTime",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["monitorData", "monitorStats"]
|
||||||
|
},
|
||||||
"NotificationChannelBody": {
|
"NotificationChannelBody": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -2426,6 +2761,9 @@
|
|||||||
"minLength": 8,
|
"minLength": 8,
|
||||||
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
|
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
|
||||||
},
|
},
|
||||||
|
"deleteProfileImage": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"profileImage": {
|
"profileImage": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "binary"
|
"format": "binary"
|
||||||
@@ -3747,7 +4085,7 @@
|
|||||||
{
|
{
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["manual", "automatic"]
|
"enum": ["automatic", "manual"]
|
||||||
},
|
},
|
||||||
"required": false,
|
"required": false,
|
||||||
"name": "resolutionType",
|
"name": "resolutionType",
|
||||||
@@ -6013,14 +6351,6 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"name": "dateRange",
|
"name": "dateRange",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
|
||||||
{
|
|
||||||
"schema": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"required": false,
|
|
||||||
"name": "normalize",
|
|
||||||
"in": "query"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -6039,10 +6369,10 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"nullable": true
|
"$ref": "#/components/schemas/UptimeDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["success", "msg"]
|
"required": ["success", "msg", "data"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10538,7 +10868,7 @@
|
|||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["refined", "modern", "bold", "editorial"]
|
"enum": ["refined", "modern", "bold", "editorial", "minimal"]
|
||||||
},
|
},
|
||||||
"themeMode": {
|
"themeMode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -10756,7 +11086,7 @@
|
|||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["refined", "modern", "bold", "editorial"]
|
"enum": ["refined", "modern", "bold", "editorial", "minimal"]
|
||||||
},
|
},
|
||||||
"themeMode": {
|
"themeMode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getHardwareDetailsByIdParamValidation,
|
getHardwareDetailsByIdParamValidation,
|
||||||
getHardwareDetailsByIdQueryValidation,
|
getHardwareDetailsByIdQueryValidation,
|
||||||
monitorResponseSchema,
|
monitorResponseSchema,
|
||||||
|
uptimeDetailsResponseSchema,
|
||||||
} from "@/api/validation/monitorValidation.js";
|
} from "@/api/validation/monitorValidation.js";
|
||||||
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
|
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
|
||||||
|
|
||||||
@@ -81,6 +82,8 @@ registry.registerPath({
|
|||||||
responses: { "200": okJson(z.array(z.string())), ...standardErrors },
|
responses: { "200": okJson(z.array(z.string())), ...standardErrors },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uptimeDetailsObject = uptimeDetailsResponseSchema.openapi("UptimeDetails");
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/monitors/uptime/details/{monitorId}",
|
path: "/monitors/uptime/details/{monitorId}",
|
||||||
@@ -88,7 +91,7 @@ registry.registerPath({
|
|||||||
summary: "Get uptime details for a monitor",
|
summary: "Get uptime details for a monitor",
|
||||||
security: bearer,
|
security: bearer,
|
||||||
request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation },
|
request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation },
|
||||||
responses: { "200": okUnknown, ...standardErrors },
|
responses: { "200": okJson(uptimeDetailsObject), ...standardErrors },
|
||||||
});
|
});
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const getMonitorByIdQueryValidation = z.object({
|
|||||||
limit: z.coerce.number().optional(),
|
limit: z.coerce.number().optional(),
|
||||||
dateRange: z.enum(DateRanges).optional(),
|
dateRange: z.enum(DateRanges).optional(),
|
||||||
numToDisplay: z.coerce.number().optional(),
|
numToDisplay: z.coerce.number().optional(),
|
||||||
normalize: booleanCoercion.optional(),
|
|
||||||
continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(),
|
continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,7 +196,6 @@ export const getUptimeDetailsByIdParamValidation = z.object({
|
|||||||
|
|
||||||
export const getUptimeDetailsByIdQueryValidation = z.object({
|
export const getUptimeDetailsByIdQueryValidation = z.object({
|
||||||
dateRange: z.enum(DateRanges),
|
dateRange: z.enum(DateRanges),
|
||||||
normalize: booleanCoercion.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const importedMonitorSchema = z
|
const importedMonitorSchema = z
|
||||||
@@ -312,3 +310,52 @@ export const monitorResponseSchema = z
|
|||||||
lastEvaluatedAt: z.number(),
|
lastEvaluatedAt: z.number(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
|
// Grouped-check buckets returned by GET /monitors/uptime/details/{monitorId}. Keep
|
||||||
|
// aligned with GroupedCheck / GroupedUptimeCheck in domain/checks/check.type.ts.
|
||||||
|
export const groupedCheckResponseSchema = z.object({
|
||||||
|
bucketDate: z.string(),
|
||||||
|
avgResponseTime: z.number(),
|
||||||
|
totalChecks: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const groupedUptimeCheckResponseSchema = groupedCheckResponseSchema.extend({
|
||||||
|
avgDns: z.number(),
|
||||||
|
avgTcp: z.number(),
|
||||||
|
avgTls: z.number(),
|
||||||
|
avgRequest: z.number(),
|
||||||
|
avgFirstByte: z.number(),
|
||||||
|
avgDownload: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep aligned with MonitorStats in domain/monitor-stats/monitor-stats.type.ts.
|
||||||
|
export const monitorStatsResponseSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
monitorId: z.string(),
|
||||||
|
avgResponseTime: z.number(),
|
||||||
|
maxResponseTime: z.number(),
|
||||||
|
totalChecks: z.number(),
|
||||||
|
totalUpChecks: z.number(),
|
||||||
|
totalDownChecks: z.number(),
|
||||||
|
uptimePercentage: z.number(),
|
||||||
|
lastCheckTimestamp: z.number(),
|
||||||
|
lastResponseTime: z.number(),
|
||||||
|
timeOfLastFailure: z.number().optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response of GET /monitors/uptime/details/{monitorId}. Keep aligned with
|
||||||
|
// UptimeDetailsResult in domain/monitors/monitor.type.ts; the monitor here is the
|
||||||
|
// repository's domain entity, which serializes `id` rather than `_id`.
|
||||||
|
export const uptimeDetailsResponseSchema = z.object({
|
||||||
|
monitorData: z.object({
|
||||||
|
monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }),
|
||||||
|
groupedChecks: z.array(groupedUptimeCheckResponseSchema),
|
||||||
|
groupedUpChecks: z.array(groupedCheckResponseSchema),
|
||||||
|
groupedDownChecks: z.array(groupedCheckResponseSchema),
|
||||||
|
groupedAvgResponseTime: z.number(),
|
||||||
|
groupedUptimePercentage: z.number(),
|
||||||
|
}),
|
||||||
|
monitorStats: monitorStatsResponseSchema.nullable(),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArra
|
|||||||
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
|
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
|
||||||
import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js";
|
import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js";
|
||||||
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
|
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
|
||||||
|
import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js";
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
|
|
||||||
type MigrationEntry = {
|
type MigrationEntry = {
|
||||||
@@ -23,6 +24,7 @@ const migrations: MigrationEntry[] = [
|
|||||||
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
|
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
|
||||||
{ name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray },
|
{ name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray },
|
||||||
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
|
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
|
||||||
|
{ name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings },
|
||||||
];
|
];
|
||||||
|
|
||||||
const runMigrations = async (logger: ILogger) => {
|
const runMigrations = async (logger: ILogger) => {
|
||||||
|
|||||||
@@ -445,11 +445,30 @@ class MongoChecksRepository implements IChecksRepository {
|
|||||||
$dateToString: { format: dateString, date: "$createdAt" },
|
$dateToString: { format: dateString, date: "$createdAt" },
|
||||||
},
|
},
|
||||||
avgResponseTime: { $avg: "$responseTime" },
|
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 },
|
totalChecks: { $sum: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ $sort: { _id: 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: [
|
groupedUpChecks: [
|
||||||
{ $match: { status: true } },
|
{ $match: { status: true } },
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ export interface GroupedCheck {
|
|||||||
totalChecks: number;
|
totalChecks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupedUptimeCheck extends GroupedCheck {
|
||||||
|
avgDns: number;
|
||||||
|
avgTcp: number;
|
||||||
|
avgTls: number;
|
||||||
|
avgRequest: number;
|
||||||
|
avgFirstByte: number;
|
||||||
|
avgDownload: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageSpeedGroupedCheck {
|
export interface PageSpeedGroupedCheck {
|
||||||
bucketDate: string;
|
bucketDate: string;
|
||||||
performance: number;
|
performance: number;
|
||||||
@@ -143,7 +152,7 @@ export interface PageSpeedGroupedCheck {
|
|||||||
|
|
||||||
export interface UptimeChecksResult {
|
export interface UptimeChecksResult {
|
||||||
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
|
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
|
||||||
groupedChecks: GroupedCheck[];
|
groupedChecks: GroupedUptimeCheck[];
|
||||||
groupedUpChecks: GroupedCheck[];
|
groupedUpChecks: GroupedCheck[];
|
||||||
groupedDownChecks: GroupedCheck[];
|
groupedDownChecks: GroupedCheck[];
|
||||||
uptimePercentage: number;
|
uptimePercentage: number;
|
||||||
@@ -159,14 +168,6 @@ export interface HasResponseTime {
|
|||||||
responseTime: number;
|
responseTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 type CheckSnapshot = Omit<Check, "metadata" | "updatedAt">;
|
||||||
|
|
||||||
export interface HardwareDiskStats {
|
export interface HardwareDiskStats {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js";
|
|
||||||
import { type Monitor } from "@/domain/monitors/monitor.type.js";
|
import { type Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import type {
|
import type {
|
||||||
MonitorType,
|
MonitorType,
|
||||||
@@ -193,9 +192,9 @@ export class MonitorService implements IMonitorService {
|
|||||||
return {
|
return {
|
||||||
monitorData: {
|
monitorData: {
|
||||||
monitor,
|
monitor,
|
||||||
groupedChecks: NormalizeDataUptimeDetails(checksData.groupedChecks, 10, 100),
|
groupedChecks: checksData.groupedChecks,
|
||||||
groupedUpChecks: NormalizeDataUptimeDetails(checksData.groupedUpChecks, 10, 100),
|
groupedUpChecks: checksData.groupedUpChecks,
|
||||||
groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100),
|
groupedDownChecks: checksData.groupedDownChecks,
|
||||||
groupedAvgResponseTime: checksData.avgResponseTime,
|
groupedAvgResponseTime: checksData.avgResponseTime,
|
||||||
groupedUptimePercentage: checksData.uptimePercentage,
|
groupedUptimePercentage: checksData.uptimePercentage,
|
||||||
},
|
},
|
||||||
@@ -364,7 +363,7 @@ export class MonitorService implements IMonitorService {
|
|||||||
const monitorsWithChecks = monitors.map((monitor: Monitor) => {
|
const monitorsWithChecks = monitors.map((monitor: Monitor) => {
|
||||||
const rawChecks = monitor.recentChecks ?? [];
|
const rawChecks = monitor.recentChecks ?? [];
|
||||||
const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type);
|
const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type);
|
||||||
const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100);
|
const checks = isSnapshotType ? rawChecks.slice(0, 1) : rawChecks;
|
||||||
monitor.recentChecks = checks;
|
monitor.recentChecks = checks;
|
||||||
return monitor;
|
return monitor;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||||
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import { NormalizeData } from "@/utils/dataUtils.js";
|
|
||||||
|
|
||||||
export interface IStatusPageService {
|
export interface IStatusPageService {
|
||||||
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
|
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
|
||||||
@@ -79,7 +78,6 @@ export class StatusPageService implements IStatusPageService {
|
|||||||
status: monitor.status,
|
status: monitor.status,
|
||||||
uptimePercentage: monitor.uptimePercentage,
|
uptimePercentage: monitor.uptimePercentage,
|
||||||
recentChecks: monitor.recentChecks,
|
recentChecks: monitor.recentChecks,
|
||||||
checks: NormalizeData(monitor.recentChecks, 10, 100),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (showURL) {
|
if (showURL) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { NormalizedCheck, CheckSnapshot } from "@/domain/checks/check.type.js";
|
|
||||||
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
|
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
|
||||||
export type StatusPageType = (typeof StatusPageTypes)[number];
|
export type StatusPageType = (typeof StatusPageTypes)[number];
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ export interface StatusPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PublicStatusPageMonitor = Pick<Monitor, "id" | "name" | "type" | "status" | "uptimePercentage" | "recentChecks"> &
|
export type PublicStatusPageMonitor = Pick<Monitor, "id" | "name" | "type" | "status" | "uptimePercentage" | "recentChecks"> &
|
||||||
Partial<Pick<Monitor, "url" | "port">> & { checks: NormalizedCheck<CheckSnapshot>[] };
|
Partial<Pick<Monitor, "url" | "port">>;
|
||||||
|
|
||||||
export interface PublicStatusPagePayload {
|
export interface PublicStatusPagePayload {
|
||||||
statusPage: StatusPage;
|
statusPage: StatusPage;
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
|||||||
if (error instanceof HTTPError || error instanceof RequestError) {
|
if (error instanceof HTTPError || error instanceof RequestError) {
|
||||||
const statusCode = error.response?.statusCode;
|
const statusCode = error.response?.statusCode;
|
||||||
const statusUp = isStatusUp(statusCode, monitor.customUpCodes);
|
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) {
|
if (!statusUp) {
|
||||||
return {
|
return {
|
||||||
@@ -184,7 +184,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
|||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
statusUp,
|
statusUp,
|
||||||
message: response.statusMessage ?? "OK",
|
message: response.statusMessage ?? "OK",
|
||||||
responseTime: response.timings.phases.firstByte ?? 0,
|
responseTime: response.timings.phases.total ?? 0,
|
||||||
timings: response.timings,
|
timings: response.timings,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
|
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
|
||||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||||
import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js";
|
import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js";
|
||||||
import type { Monitor, MonitorStatus } from "@/domain/monitors/monitor.type.js";
|
import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js";
|
||||||
import type {
|
import type {
|
||||||
DockerStatusPayload,
|
DockerStatusPayload,
|
||||||
GameStatusPayload,
|
GameStatusPayload,
|
||||||
@@ -218,10 +218,13 @@ export class StatusService implements IStatusService {
|
|||||||
const patch: Partial<Monitor> = {};
|
const patch: Partial<Monitor> = {};
|
||||||
|
|
||||||
// Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down
|
// Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down
|
||||||
|
// Unknown values (e.g. legacy boolean statuses from old versions) are resolved the same way,
|
||||||
|
// otherwise computeReachability can never transition them and they stay stuck forever.
|
||||||
|
const isUnknownStatus = !MonitorStatuses.includes(monitor.status);
|
||||||
let newStatus: MonitorStatus = monitor.status;
|
let newStatus: MonitorStatus = monitor.status;
|
||||||
let statusChanged = false;
|
let statusChanged = false;
|
||||||
if (monitor.status === "initializing" || monitor.status === "maintenance") {
|
if (monitor.status === "initializing" || monitor.status === "maintenance" || isUnknownStatus) {
|
||||||
newStatus = status === true ? "up" : "down";
|
newStatus = status ? "up" : "down";
|
||||||
patch.status = newStatus;
|
patch.status = newStatus;
|
||||||
statusChanged = newStatus === "down";
|
statusChanged = newStatus === "down";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { GroupedCheck, HasResponseTime, NormalizedCheck, NormalizedUptimeCheck } from "@/domain/checks/check.type.js";
|
|
||||||
import { type DateRange } from "@/types/query.js";
|
import { type DateRange } from "@/types/query.js";
|
||||||
|
|
||||||
export const getDateForRange = (dateRange: DateRange): Date => {
|
export const getDateForRange = (dateRange: DateRange): Date => {
|
||||||
@@ -32,46 +31,3 @@ export const getDateFormat = (dateRange: DateRange): string => {
|
|||||||
};
|
};
|
||||||
return formatLookup[dateRange];
|
return formatLookup[dateRange];
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentileBy = <T>(arr: T[], percentile: number, value: (item: T) => number): number => {
|
|
||||||
const sorted = arr.slice().sort((a, b) => value(a) - value(b));
|
|
||||||
const index = (percentile / 100) * (sorted.length - 1);
|
|
||||||
const lower = Math.floor(index);
|
|
||||||
const upper = lower + 1;
|
|
||||||
const weight = index % 1;
|
|
||||||
if (upper >= sorted.length) return value(sorted[lower]!);
|
|
||||||
return value(sorted[lower]!) * (1 - weight) + value(sorted[upper]!) * weight;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rescale = (raw: number, min: number, max: number, rangeMin: number, rangeMax: number): number => {
|
|
||||||
const scaled = rangeMin + ((raw - min) * (rangeMax - rangeMin)) / (max - min);
|
|
||||||
return Math.max(rangeMin, Math.min(rangeMax, scaled));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NormalizeData = <T extends HasResponseTime>(checks: T[], rangeMin: number, rangeMax: number): NormalizedCheck<T>[] => {
|
|
||||||
const original = (check: T) => (typeof check.responseTime === "number" ? check.responseTime : 0);
|
|
||||||
if (checks.length <= 1) {
|
|
||||||
return checks.map((check) => ({ ...check, originalResponseTime: original(check) }));
|
|
||||||
}
|
|
||||||
const min = percentileBy(checks, 0, (c) => c.responseTime);
|
|
||||||
const max = percentileBy(checks, 95, (c) => c.responseTime);
|
|
||||||
return checks.map((check) => ({
|
|
||||||
...check,
|
|
||||||
responseTime: rescale(check.responseTime, min, max, rangeMin, rangeMax),
|
|
||||||
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),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { MongoMemoryServer } from "mongodb-memory-server";
|
||||||
|
import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts";
|
||||||
|
import { CheckModel } from "../../src/domain/checks/check.model.ts";
|
||||||
|
import type { UptimeChecksResult } from "../../src/domain/checks/check.type.ts";
|
||||||
|
import type { ILogger } from "../../src/utils/logger.ts";
|
||||||
|
import { createMockLogger } from "../helpers/createMockLogger.ts";
|
||||||
|
|
||||||
|
// ── Real-Mongo harness ─────────────────────────────────────────────────────────
|
||||||
|
// The guarantee under test — every phase average uses the full bucket as its
|
||||||
|
// denominator — IS the $ifNull-inside-$avg behavior of the groupedChecks facet, so it
|
||||||
|
// can only be exercised against a live aggregation engine. Reused keep-alive sockets
|
||||||
|
// produce checks with no timings subdocument at all; without the coercion, $avg would
|
||||||
|
// skip them and average dns/tcp/tls over fresh-connection checks only.
|
||||||
|
|
||||||
|
let mongod: MongoMemoryServer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongod = await MongoMemoryServer.create();
|
||||||
|
await mongoose.connect(mongod.getUri());
|
||||||
|
await CheckModel.createCollection(); // timeseries collections must exist before insert
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongod.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await CheckModel.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MONITOR_ID = new mongoose.Types.ObjectId();
|
||||||
|
const TEAM_ID = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// One shared createdAt puts every seeded check in the same hourly bucket of the "day"
|
||||||
|
// range, keeping the facet's group stage deterministic.
|
||||||
|
const BUCKET_TIME = new Date(Date.now() - 10 * 60 * 1000);
|
||||||
|
|
||||||
|
const seedCheck = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
CheckModel.create({
|
||||||
|
metadata: { monitorId: MONITOR_ID, teamId: TEAM_ID, type: "http" },
|
||||||
|
status: true,
|
||||||
|
responseTime: 100,
|
||||||
|
createdAt: BUCKET_TIME,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findGrouped = async (repo: MongoChecksRepository) =>
|
||||||
|
(await repo.findByDateRangeAndMonitorId(MONITOR_ID.toString(), "day", { type: "http" })) as UptimeChecksResult;
|
||||||
|
|
||||||
|
describe("MongoChecksRepository groupedChecks facet", () => {
|
||||||
|
let repo: MongoChecksRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new MongoChecksRepository(createMockLogger() as unknown as ILogger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("averages each phase over the full bucket, counting checks without timings as 0", async () => {
|
||||||
|
await seedCheck({
|
||||||
|
responseTime: 100,
|
||||||
|
timings: { phases: { dns: 10, tcp: 20, tls: 30, request: 8, firstByte: 40, download: 6 } },
|
||||||
|
});
|
||||||
|
await seedCheck({ responseTime: 200 }); // reused socket: no timings subdocument at all
|
||||||
|
|
||||||
|
const result = await findGrouped(repo);
|
||||||
|
|
||||||
|
expect(result.groupedChecks).toHaveLength(1);
|
||||||
|
// Plain $avg would skip the timings-less check and report 10/20/30/8/40/6 here.
|
||||||
|
expect(result.groupedChecks[0]).toMatchObject({
|
||||||
|
totalChecks: 2,
|
||||||
|
avgDns: 5,
|
||||||
|
avgTcp: 10,
|
||||||
|
avgTls: 15,
|
||||||
|
avgRequest: 4,
|
||||||
|
avgFirstByte: 20,
|
||||||
|
avgDownload: 3,
|
||||||
|
avgResponseTime: 150,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yields 0 (not null) for every phase in a bucket of only timings-less checks", async () => {
|
||||||
|
await seedCheck();
|
||||||
|
await seedCheck();
|
||||||
|
|
||||||
|
const result = await findGrouped(repo);
|
||||||
|
|
||||||
|
expect(result.groupedChecks).toHaveLength(1);
|
||||||
|
// The client sums these fields for the stacked chart; null would poison the math.
|
||||||
|
expect(result.groupedChecks[0]).toMatchObject({
|
||||||
|
totalChecks: 2,
|
||||||
|
avgDns: 0,
|
||||||
|
avgTcp: 0,
|
||||||
|
avgTls: 0,
|
||||||
|
avgRequest: 0,
|
||||||
|
avgFirstByte: 0,
|
||||||
|
avgDownload: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,7 +112,7 @@ describe("HttpProvider", () => {
|
|||||||
status: true,
|
status: true,
|
||||||
code: 200,
|
code: 200,
|
||||||
message: "OK",
|
message: "OK",
|
||||||
responseTime: 100,
|
responseTime: 120,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -182,8 +182,8 @@ describe("HttpProvider", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults responseTime to 0 when timings.phases.firstByte is undefined", async () => {
|
it("defaults responseTime to 0 when timings.phases.total is undefined", async () => {
|
||||||
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { firstByte: undefined } } }));
|
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } }));
|
||||||
const { provider } = createProvider();
|
const { provider } = createProvider();
|
||||||
|
|
||||||
const result = await provider.handle(makeMonitor());
|
const result = await provider.handle(makeMonitor());
|
||||||
@@ -224,11 +224,11 @@ describe("HttpProvider", () => {
|
|||||||
expect(result.message).toBe("Response is not JSON");
|
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(
|
mockGot.mockResolvedValue(
|
||||||
makeGotResponse({
|
makeGotResponse({
|
||||||
headers: { "content-type": "text/html" },
|
headers: { "content-type": "text/html" },
|
||||||
timings: { phases: { firstByte: undefined } },
|
timings: { phases: { total: undefined } },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const { provider } = createProvider();
|
const { provider } = createProvider();
|
||||||
|
|||||||
@@ -315,8 +315,8 @@ describe("StatusPageService", () => {
|
|||||||
status: "up",
|
status: "up",
|
||||||
uptimePercentage: 99.9,
|
uptimePercentage: 99.9,
|
||||||
recentChecks: [],
|
recentChecks: [],
|
||||||
checks: [],
|
|
||||||
});
|
});
|
||||||
|
expect(monitors[0]).not.toHaveProperty("checks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders monitors to match the status page's monitor list", async () => {
|
it("orders monitors to match the status page's monitor list", async () => {
|
||||||
|
|||||||
@@ -498,6 +498,45 @@ describe("StatusService", () => {
|
|||||||
expect(result.monitor.status).toBe("down");
|
expect(result.monitor.status).toBe("down");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves a legacy boolean status ('true') to 'up' on a passing check (regression: pre-upgrade monitors stuck forever)", async () => {
|
||||||
|
// Old Checkmate versions stored monitor.status as a boolean. Pre-fix, a monitor
|
||||||
|
// carrying status='true' never matched the initializing override and
|
||||||
|
// computeReachability only transitions to 'up' from 'down', so the legacy value
|
||||||
|
// was written back unchanged on every evaluation — stuck forever while the UI
|
||||||
|
// rendered it as 'initializing'.
|
||||||
|
const monitor = makeMonitor({
|
||||||
|
statusWindow: [true, true, true, true, true],
|
||||||
|
statusWindowSize: 5,
|
||||||
|
statusWindowThreshold: 80,
|
||||||
|
status: "true" as MonitorStatus,
|
||||||
|
});
|
||||||
|
const { service, monitorsRepository } = createService();
|
||||||
|
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||||
|
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||||
|
|
||||||
|
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor);
|
||||||
|
|
||||||
|
expect(result.monitor.status).toBe("up");
|
||||||
|
expect(result.statusChanged).toBe(false); // was effectively up already — no notification storm
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a legacy boolean status ('false') to 'down' on a failing check (regression mirror)", async () => {
|
||||||
|
const monitor = makeMonitor({
|
||||||
|
statusWindow: [false, false, false, false, false],
|
||||||
|
statusWindowSize: 5,
|
||||||
|
statusWindowThreshold: 80,
|
||||||
|
status: "false" as MonitorStatus,
|
||||||
|
});
|
||||||
|
const { service, monitorsRepository } = createService();
|
||||||
|
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||||
|
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||||
|
|
||||||
|
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor);
|
||||||
|
|
||||||
|
expect(result.monitor.status).toBe("down");
|
||||||
|
expect(result.statusChanged).toBe(true); // down must surface so an incident is opened
|
||||||
|
});
|
||||||
|
|
||||||
it("during warmup, sub-threshold raw checks no longer flip stored status (regression: down-during-init incident not resolving)", async () => {
|
it("during warmup, sub-threshold raw checks no longer flip stored status (regression: down-during-init incident not resolving)", async () => {
|
||||||
// Once the monitor has left 'initializing', the warmup branch must NOT silently
|
// Once the monitor has left 'initializing', the warmup branch must NOT silently
|
||||||
// flip status from raw checks. Otherwise a down check during warmup writes
|
// flip status from raw checks. Otherwise a down check during warmup writes
|
||||||
|
|||||||
@@ -1,26 +1,7 @@
|
|||||||
import { describe, expect, it } from "@jest/globals";
|
import { describe, expect, it } from "@jest/globals";
|
||||||
import { getDateForRange, getDateFormat, NormalizeData, NormalizeDataUptimeDetails } from "../../../src/utils/dataUtils.ts";
|
import { getDateForRange, getDateFormat } from "../../../src/utils/dataUtils.ts";
|
||||||
import type { GroupedCheck } from "../../../src/domain/checks/check.type.ts";
|
|
||||||
import { DateRanges, type DateRange } from "../../../src/types/query.ts";
|
import { DateRanges, type DateRange } from "../../../src/types/query.ts";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const makeCheck = (responseTime: number, overrides?: Record<string, unknown>) => ({
|
|
||||||
id: `check-${responseTime}`,
|
|
||||||
status: true,
|
|
||||||
responseTime,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeGrouped = (avgResponseTime: number, overrides?: Partial<GroupedCheck>): GroupedCheck => ({
|
|
||||||
bucketDate: `bucket-${avgResponseTime}`,
|
|
||||||
avgResponseTime,
|
|
||||||
totalChecks: 5,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("getDateForRange", () => {
|
describe("getDateForRange", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["recent", 2 * 60 * 60 * 1000],
|
["recent", 2 * 60 * 60 * 1000],
|
||||||
@@ -61,115 +42,3 @@ describe("getDateFormat", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NormalizeData", () => {
|
|
||||||
// Fixture math: p0 of [100..500] is 100; p95 interpolates between the 4th and 5th
|
|
||||||
// sorted values at weight 0.8 → 400*0.2 + 500*0.8 = 480. Values rescale into
|
|
||||||
// [10, 100] via 10 + (rt - 100) * 90 / 380, clamped.
|
|
||||||
it("rescales responseTime into [rangeMin, rangeMax] anchored to the p0–p95 window", () => {
|
|
||||||
const checks = [100, 200, 300, 400, 500].map((rt) => makeCheck(rt));
|
|
||||||
|
|
||||||
const result = NormalizeData(checks, 10, 100);
|
|
||||||
|
|
||||||
expect(result[0]!.responseTime).toBe(10);
|
|
||||||
expect(result[1]!.responseTime).toBeCloseTo(33.6842, 4);
|
|
||||||
expect(result[2]!.responseTime).toBeCloseTo(57.3684, 4);
|
|
||||||
expect(result[3]!.responseTime).toBeCloseTo(81.0526, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps values above the p95 anchor to rangeMax", () => {
|
|
||||||
const checks = [100, 200, 300, 400, 500].map((rt) => makeCheck(rt));
|
|
||||||
|
|
||||||
const result = NormalizeData(checks, 10, 100);
|
|
||||||
|
|
||||||
expect(result[4]!.responseTime).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the raw value in originalResponseTime and spreads other fields", () => {
|
|
||||||
const checks = [makeCheck(100, { message: "OK" }), makeCheck(500)];
|
|
||||||
|
|
||||||
const result = NormalizeData(checks, 10, 100);
|
|
||||||
|
|
||||||
expect(result[0]!.originalResponseTime).toBe(100);
|
|
||||||
expect(result[1]!.originalResponseTime).toBe(500);
|
|
||||||
expect(result[0]).toMatchObject({ id: "check-100", status: true, message: "OK" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes a single check through, only adding originalResponseTime", () => {
|
|
||||||
const result = NormalizeData([makeCheck(250)], 10, 100);
|
|
||||||
|
|
||||||
expect(result).toEqual([{ id: "check-250", status: true, responseTime: 250, originalResponseTime: 250 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty array for empty input", () => {
|
|
||||||
expect(NormalizeData([], 10, 100)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records originalResponseTime as 0 for a non-numeric responseTime (failed checks)", () => {
|
|
||||||
const checks = [makeCheck(undefined as unknown as number), makeCheck(100), makeCheck(200)];
|
|
||||||
|
|
||||||
const result = NormalizeData(checks, 10, 100);
|
|
||||||
|
|
||||||
expect(result[0]!.originalResponseTime).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Characterization, not endorsement: when every value is equal the p0–p95 window
|
|
||||||
// has zero width and the rescale divides by zero. Fixing this is a planned
|
|
||||||
// follow-up (docs/planning/dataUtils-generic-normalization-refactor.md); this
|
|
||||||
// test pins the current behavior so the fix is a deliberate, visible change.
|
|
||||||
it("produces NaN when all values are equal (division by zero — known edge case)", () => {
|
|
||||||
const result = NormalizeData([makeCheck(50), makeCheck(50), makeCheck(50)], 10, 100);
|
|
||||||
|
|
||||||
result.forEach((check) => {
|
|
||||||
expect(check.responseTime).toBeNaN();
|
|
||||||
expect(check.originalResponseTime).toBe(50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user