Compare commits

...
Author SHA1 Message Date
Alex Holliday e81fdb9810 remove unused hook 2026-07-31 11:50:02 -07:00
Alex Holliday 9712579c4d remove unused imports 2026-07-31 11:45:41 -07:00
Alex Holliday 98e0ffc983 update tooltips 2026-07-31 11:41:18 -07:00
Alex Holliday ac251c4d3f update tests 2026-07-31 11:36:20 -07:00
Alex Holliday 08f167c18e cap values at p95 on client 2026-07-31 11:36:10 -07:00
Alex Holliday 8a608961e6 remove server side normalization 2026-07-31 10:31:39 -07:00
Alexander Holliday 093553f9ba Merge pull request #3818 from bluewave-labs/feat/chart-cleanup
feat: chart cleanup
2026-07-30 12:16:24 -07:00
Alex Holliday e5282b9099 opacity -> 0.2 2026-07-30 12:04:25 -07:00
Alex Holliday 6b4444038c add tests 2026-07-30 11:54:19 -07:00
Alex Holliday 89464e9be4 add legend 2026-07-30 11:54:12 -07:00
Alex Holliday 6856914d41 extract hardcoded strings 2026-07-30 10:56:24 -07:00
Alex Holliday c102cd8d26 openapi schemas 2026-07-30 10:35:01 -07:00
Alexander Holliday ff28e74aca Merge pull request #3797 from LvckyAPI/fix-incorrect-status
Fix monitoring status stuck
2026-07-30 09:44:15 -07:00
Alexander Holliday 23b420d072 Merge pull request #3815 from bluewave-labs/feat/stacked-timing-chart
feat: stacked timing chart
2026-07-29 12:42:50 -07:00
Alex Holliday 0172272dc3 update tests 2026-07-29 12:33:56 -07:00
Alex Holliday 3f088c70f8 universal ymax cap 2026-07-29 12:30:36 -07:00
Alex Holliday ed54fa4668 first byte to total repsonse 2026-07-29 12:30:18 -07:00
Alex Holliday 0e0cace676 merge charts 2026-07-29 11:45:49 -07:00
Alex Holliday 58f1ae7fe9 extract xtick 2026-07-29 11:04:11 -07:00
Alex Holliday e3075318b6 denormalize resposne times 2026-07-29 10:57:20 -07:00
Alex Holliday 81aab35961 conditionally render charts 2026-07-28 15:27:19 -07:00
Alex Holliday 4e495eed33 add chart colors to theme 2026-07-28 15:27:04 -07:00
Alex Holliday 061da5e7ef coerce values to 0 if null 2026-07-28 15:26:52 -07:00
Alex Holliday c7662727e7 add area charts 2026-07-28 11:24:23 -07:00
Alex Holliday 13c921531c update types, add skeleton for stacked timing phase chart 2026-07-28 11:13:50 -07:00
Alex Holliday 0cf8cfa5dc add avg timing phases to details response 2026-07-28 11:13:35 -07:00
Alex Holliday 4182a964a6 minor version bump 2026-07-28 09:48:31 -07:00
Iven Schlenther 04f7ef252a Fix indentation in statusService.ts for isUnknownStatus assignment. 2026-07-27 15:07:00 +02:00
Iven Schlenther 3201653f19 Fix monitor status handling: replace isKnownStatus with isUnknownStatus for clarity and simplify initialization logic. 2026-07-27 15:03:43 +02:00
Iven Schlenther 0988a61c1d Resolve legacy monitor status issues: handle unknown statuses, fix stuck states from old boolean values, and add regression tests. Adjust BaseChart text color for theme compatibility. 2026-07-27 15:03:41 +02:00
Alexander Holliday 80ea09b864 Merge pull request #3801 from bluewave-labs/chore/cleanup
chore: cleanup
2026-07-24 12:30:08 -07:00
Alex Holliday e0228918fb fix tests 2026-07-24 12:28:45 -07:00
Alex Holliday 8369db5ca2 remove logger 2026-07-24 11:38:08 -07:00
Alex Holliday 49e8d26bfc remove legacy try/catch blocks 2026-07-24 11:34:54 -07:00
Alex Holliday c28be97fb0 update tests 2026-07-24 11:32:18 -07:00
Alex Holliday ed36db172a dedupe hardware stat definitions, fix tests, remove misleading null return types 2026-07-24 11:32:00 -07:00
Alex Holliday c021d04f53 extract swtich 2026-07-24 11:09:53 -07:00
Alex Holliday 86eeda3f0e replace error instances with AppError 2026-07-24 10:49:39 -07:00
Alex Holliday 25d81f4238 remove dead findByMonitorIdAndDateRange method 2026-07-24 10:43:56 -07:00
Alexander Holliday af8ee643e1 Merge pull request #3800 from bluewave-labs/chore/monitor-domain-refactor
extact monitor children deletion to helper
2026-07-23 11:25:12 -07:00
Alex Holliday 61e442493a extact monitor children deletion to helper 2026-07-23 10:49:59 -07:00
Alexander Holliday 9540084425 Merge pull request #3799 from bluewave-labs/chore/domain-refactoring
chore: domain refactoring
2026-07-23 10:30:33 -07:00
Alex Holliday f50ba1bf23 update tests 2026-07-23 10:26:01 -07:00
Alex Holliday b4e42bfd68 add type check script 2026-07-23 10:25:51 -07:00
Alex Holliday 3af00281c4 extract date range calculation for conisistency 2026-07-23 10:25:24 -07:00
Alex Holliday 4a90ab8e78 extract pagination 2026-07-23 09:36:06 -07:00
Alexander Holliday 7598cf3007 Merge pull request #3798 from bluewave-labs/chore/api-cleanup
chore: api cleanup
2026-07-22 11:03:07 -07:00
Alex Holliday e1c89cd093 dedupe normalization 2026-07-22 11:00:59 -07:00
Alex Holliday 80d4e818f1 remove unused static vars 2026-07-22 10:36:16 -07:00
Alex Holliday 4949acac1c remove unused email templates, remove duplicate constants 2026-07-22 10:26:00 -07:00
Alex Holliday 586e79efb2 remove unused getters 2026-07-22 10:13:37 -07:00
Alex Holliday c961f5292d fix signature, add tests 2026-07-22 10:05:36 -07:00
Alex Holliday d53785b768 remove unused utilities, fix delete profile image issue 2026-07-22 10:03:48 -07:00
Alexander Holliday b9cbf199e6 Merge pull request #3796 from LvckyAPI/develop
Fixes UI bug with German translations
2026-07-22 09:44:19 -07:00
Iven Schlenther ac6678a986 Update English and German translations for "temp" label to "CPU temperature" in locales files. 2026-07-22 11:33:06 +02:00
Iven Schlenther ed1ef56184 - Add sx={{ overflowWrap: "anywhere" }} to Typography in BaseChart for improved text wrapping.
- Adjust `alignItems` to `stretch` and add `maxWidth` (260) to Gauge components in Infrastructure Details page.
- Update German translations for memory titles, temperatures, and labels in locales file.
2026-07-22 11:27:38 +02:00
Alexander Holliday ac16126e2e Merge pull request #3795 from bluewave-labs/fix/enums
fix: remove inline enum definitions
2026-07-21 10:53:52 -07:00
Alex Holliday 8e600e9a87 remove inline enum definitions 2026-07-21 10:48:18 -07:00
Alexander Holliday 790d83b7ed Merge pull request #3794 from bluewave-labs/fix/positional-args
fix: positional args
2026-07-21 10:30:29 -07:00
Alex Holliday c567e4be5e Update tests 2026-07-21 10:26:10 -07:00
Alex Holliday 936d9cd9a0 update call site 2026-07-21 10:26:02 -07:00
Alex Holliday 6e047d2cdb convert positional args to config obj 2026-07-21 10:25:50 -07:00
Alex Holliday aad03b787f update tests 2026-07-21 10:07:18 -07:00
Alex Holliday bb374db463 remove stray import 2026-07-21 10:07:09 -07:00
Alex Holliday 57e008c7ba update services config 2026-07-21 10:05:17 -07:00
Alex Holliday 5d3dc03c89 add a provider registry to notificaiton service, replace positional args and switch statements 2026-07-21 10:02:20 -07:00
155 changed files with 2108 additions and 1754 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.9.2",
"version": "3.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.9.2",
"version": "3.10.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.9.2",
"version": "3.10.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -7,6 +7,7 @@ import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { useTranslation } from "react-i18next";
import prettyMilliseconds from "pretty-ms";
type HeatmapCheck =
| CheckSnapshot
@@ -58,7 +59,9 @@ export const HeatmapResponseTimeTooltip = ({
</Typography>
<Typography>
{t("common.labels.responseTime")}:{" "}
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
{check?.responseTime
? prettyMilliseconds(check.responseTime, { compact: true })
: "N/A"}
</Typography>
<Typography textTransform={"capitalize"}>
Status:{" "}
@@ -8,6 +8,7 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -29,12 +30,12 @@ const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats
return { max: "-", avg: "-" };
}
const validChecks = checks.filter((check) => check.originalResponseTime != null);
const validChecks = checks.filter((check) => check.responseTime != null);
if (validChecks.length === 0) {
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 avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
@@ -54,7 +55,7 @@ export const HistogramResponseTime = ({
const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
@@ -111,7 +112,7 @@ export const HistogramResponseTime = ({
>
{data.map((check, index) => {
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 =
check.status === true
? theme.palette.success.light
@@ -13,6 +13,7 @@ type BaseChartProps = React.PropsWithChildren<{
width?: number | string;
maxWidth?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>;
export const BaseChart = ({
@@ -21,16 +22,22 @@ export const BaseChart = ({
width = "100%",
maxWidth = "100%",
padding,
onClick,
}: BaseChartProps) => {
const theme = useTheme();
return (
<BaseBox
onClick={onClick}
sx={{
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
width: width,
maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}}
>
<Stack
@@ -40,6 +47,7 @@ export const BaseChart = ({
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
+14 -4
View File
@@ -1,18 +1,28 @@
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 {
color?: string;
size?: string;
size?: DotSize;
style?: CSSProperties;
}
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
return (
<span
style={{
content: '""',
width: size,
height: size,
width: DOT_SIZE_PX[size],
height: DOT_SIZE_PX[size],
borderRadius: "50%",
backgroundColor: color,
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 { Clock } from "lucide-react";
import { Dot } from "@/Components/design-elements";
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography";
import prettyMilliseconds from "pretty-ms";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useSelector } from "react-redux";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
import type { GroupedCheck } from "@/Types/Check";
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
import { useMemo, useState } from "react";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
PHASE_COLOR_KEYS,
PHASE_KEYS,
PHASE_LABEL_KEYS,
} from "@/Components/monitors/charts/ChartUtils";
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type DetailsCheck = GroupedCheck &
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
@@ -63,17 +50,72 @@ const ResponseTimeToolTip = ({
theme,
uiTimezone,
}: ResponseTimeToolTipProps) => {
const { t } = useTranslation();
if (!label) return null;
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography>Response time: {responseTime} ms</Typography>
<Typography>
{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>
);
};
@@ -82,11 +124,28 @@ export const HistogramDetails = ({
checks,
range,
}: {
checks: GroupedCheck[];
checks: DetailsCheck[];
range: string;
}) => {
const [showTimingPhases, setShowTimingPhases] = useState(false);
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
const { t } = useTranslation();
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const yMax = useMemo(() => {
const totals = checks.map((check) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return (
<BaseChart
icon={
@@ -95,7 +154,15 @@ export const HistogramDetails = ({
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
width="100%"
@@ -128,7 +195,34 @@ export const HistogramDetails = ({
stopOpacity={0}
/>
</linearGradient>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis
axisLine={false}
tickLine={false}
@@ -141,24 +235,99 @@ export const HistogramDetails = ({
)}
/>
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
{showTimingPhases ? (
<Tooltip
content={(props) => (
<TimingPhasesToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
) : (
<Tooltip
content={(props) => (
<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>
</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>
);
};
@@ -8,7 +8,7 @@ import {
ResponsiveContainer,
} from "recharts";
import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
@@ -1,6 +1,6 @@
import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors";
import { XTick } from "@/Components/monitors/charts/XTick";
import {
XAxis,
AreaChart,
@@ -0,0 +1,30 @@
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { Text } from "recharts";
import { useTheme } from "@mui/material";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
@@ -25,10 +25,11 @@ export const InfraDetailsGauges = ({
<Stack
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"center"}
alignItems={"stretch"}
>
<DetailGauge
title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
@@ -37,6 +38,7 @@ export const InfraDetailsGauges = ({
/>
<DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
@@ -49,6 +51,7 @@ export const InfraDetailsGauges = ({
key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={260}
progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)}
+1 -2
View File
@@ -13,13 +13,12 @@ const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = [];
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 80) + 20;
const responseTime = Math.floor(Math.random() * 800) + 20;
checks.push({
id: `dummy-${i}`,
status: isUp,
statusCode: isUp ? 200 : 500,
responseTime: responseTime,
originalResponseTime: responseTime,
message: "",
createdAt: new Date(Date.now() - i * 60000).toISOString(),
});
@@ -29,7 +29,7 @@ import {
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { formatPercentage } from "@/Utils/FormatUtils";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
type StatusPageMonitor = Monitor;
export interface BaseStyles {
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 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 { useTranslation } from "react-i18next";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
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;
@@ -29,7 +26,6 @@ const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [
@@ -53,24 +49,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
);
}
const kind = classify(check);
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>
);
const tooltipContent = <ThemedChartTooltip check={check} />;
return (
<Tooltip
key={check.id ?? i}
@@ -2,13 +2,12 @@ import { useMemo } from "react";
import Tooltip from "@mui/material/Tooltip";
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 { useTranslation } from "react-i18next";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
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 MIN_HEIGHT_PCT = 6;
@@ -33,20 +32,20 @@ export const ThemedHistogram = ({
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const { padded, max, avg, peak } = useMemo(() => {
const { padded, heights, avg, peak } = useMemo(() => {
const source = checks.slice(-CELLS);
const out: (CheckSnapshot | null)[] = [
...source,
...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 maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
const avgRt = valid.length
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
: 0;
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
return { padded: out, heights, avg: avgRt, peak: maxRt };
}, [checks]);
return (
@@ -61,32 +60,8 @@ export const ThemedHistogram = ({
/>
);
}
const height = Math.max(
MIN_HEIGHT_PCT,
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>
);
const height = heights[i] ?? MIN_HEIGHT_PCT;
const tooltipContent = <ThemedChartTooltip check={check} />;
return (
<Tooltip
key={check.id ?? i}
@@ -18,7 +18,7 @@ export interface InfraSx {
}
interface Props {
monitor: Monitor & { checks?: Monitor["recentChecks"] };
monitor: Monitor;
sxApi: InfraSx;
}
@@ -36,7 +36,7 @@ const heatLevel = (value: number): GaugeFillLevel =>
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
const { t } = useTranslation();
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
const latest = monitor.recentChecks?.[0];
const renderEmpty = () => (
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
+11 -3
View File
@@ -57,7 +57,6 @@ const UptimeDetailsPage = () => {
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
params.append("normalize", "true");
return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
}, [monitorId, dateRange]);
@@ -72,6 +71,7 @@ const UptimeDetailsPage = () => {
);
const monitorData = monitorDetailsData?.monitorData;
const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
@@ -165,10 +165,18 @@ const UptimeDetailsPage = () => {
{ keepPreviousData: true, revalidateOnFocus: false }
);
if (!monitorData) {
return null;
}
if (!monitor) {
return null;
}
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
const geoLocations = monitor?.geoCheckLocations;
const geoLocations = monitor.geoCheckLocations;
const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0;
@@ -215,7 +223,7 @@ const UptimeDetailsPage = () => {
/>
</Stack>
<HistogramDetails
checks={monitorData?.groupedChecks || []}
checks={monitorData.groupedChecks}
range={dateRange}
/>
<ChecksTable
+10 -12
View File
@@ -149,6 +149,15 @@ export interface GroupedCheck {
totalChecks: number;
}
export interface GroupedUptimeCheck extends GroupedCheck {
avgDns: number;
avgTcp: number;
avgTls: number;
avgRequest: number;
avgFirstByte: number;
avgDownload: number;
}
export interface PageSpeedGroupedCheck {
bucketDate: string;
performance: number;
@@ -241,18 +250,7 @@ export interface ChecksSummary {
downChecks: number;
}
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
originalResponseTime: number;
};
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt">;
export interface HasResponseTime {
responseTime: number;
}
export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
originalResponseTime: number;
};
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
+2 -2
View File
@@ -1,4 +1,4 @@
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
import type { GroupedCheck, CheckSnapshot, GroupedUptimeCheck } from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
@@ -137,7 +137,7 @@ export interface MonitorStats {
export interface MonitorData {
monitor: Monitor;
groupedChecks: GroupedCheck[];
groupedChecks: GroupedUptimeCheck[];
groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[];
groupedAvgResponseTime: number;
+29 -44
View File
@@ -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.
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
export const getResponseColor = (
@@ -129,3 +85,32 @@ export const getResponseColor = (
}
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));
});
};
+31
View File
@@ -20,8 +20,19 @@ export const colors = {
brandGreen: "#13715B",
brandGreenLight: "#4DAF94",
green200: "#ECF7F2",
green700: "#008300",
yellow200: "#FFF4E5",
yellow500: "#EDA100",
yellow600: "#C98500",
orange500: "#FFA500",
orange600: "#EB6834",
orange700: "#D95926",
blue400: "#3987E5",
blue500: "#2A78D6",
aqua500: "#1BAF7A",
aqua600: "#199E70",
magenta300: "#E87BA4",
magenta400: "#D55181",
};
export const lightPalette = {
@@ -38,6 +49,16 @@ export const lightPalette = {
running: colors.green200,
paused: colors.yellow200,
},
chart: {
phases: {
dns: colors.magenta300,
tcp: colors.orange600,
tls: colors.aqua500,
request: colors.yellow500,
firstByte: colors.blue500,
download: colors.green700,
},
},
};
export const darkPalette = {
@@ -58,4 +79,14 @@ export const darkPalette = {
running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18),
},
chart: {
phases: {
dns: colors.magenta400,
tcp: colors.orange700,
tls: colors.aqua600,
request: colors.yellow600,
firstByte: colors.blue500,
download: colors.green700,
},
},
};
+20
View File
@@ -16,10 +16,30 @@ declare module "@mui/material/styles" {
interface Palette {
sidebar: { accent: string };
rowStatus: { running: string; paused: string };
chart: {
phases: {
dns: string;
tcp: string;
tls: string;
request: string;
firstByte: string;
download: string;
};
};
}
interface PaletteOptions {
sidebar?: { accent: string };
rowStatus?: { running: string; paused: string };
chart?: {
phases: {
dns: string;
tcp: string;
tls: string;
request: string;
firstByte: string;
download: string;
};
};
}
}
+4 -4
View File
@@ -825,7 +825,7 @@
"netBytesRecv": "{{name}} - Empfangene Bytes",
"netBytesSent": "{{name}} - Gesendete Bytes",
"netNoTraffic": "Capture meldet null Datenverkehr auf allen Schnittstellen. Das ist normal, wenn Capture in einem Container läuft, der nur Loopback-Schnittstellen sieht — führe Capture direkt auf dem Host aus, um echten Netzwerkverkehr zu sehen.",
"temp": "Temp."
"temp": "CPU-Temperatur"
}
},
"gauges": {
@@ -841,13 +841,13 @@
},
"memory": {
"lowerLabel": "Frei",
"title": "Arbeitsspeicherauslastung",
"title": "Speicherauslastung",
"upperLabel": "Belegt"
}
},
"statBoxes": {
"avgCpuTemperature": "Durchschn. CPU Termperatur",
"cpuFrequency": "CPU Frequenz",
"avgCpuTemperature": "Durchschn. CPU-Temperatur",
"cpuFrequency": "CPU-Frequenz",
"cpuLogical": "CPU (logisch)",
"cpuPhysical": "CPU (physisch)",
"disk": "Festplatte",
+15 -2
View File
@@ -68,11 +68,24 @@
"downtime": "Downtime",
"high": "High",
"low": "Low",
"responseTime": "Response time",
"timingPhases": "Timing phases",
"totalResponseTime": "Total response time",
"uptime": "Uptime"
},
"histogram": {
"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": {
@@ -845,7 +858,7 @@
"netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent",
"loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.",
"temp": "Temp"
"temp": "CPU temperature"
}
},
"gauges": {
+343 -13
View File
@@ -536,6 +536,341 @@
"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": {
"oneOf": [
{
@@ -2426,6 +2761,9 @@
"minLength": 8,
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
},
"deleteProfileImage": {
"type": "boolean"
},
"profileImage": {
"type": "string",
"format": "binary"
@@ -3747,7 +4085,7 @@
{
"schema": {
"type": "string",
"enum": ["manual", "automatic"]
"enum": ["automatic", "manual"]
},
"required": false,
"name": "resolutionType",
@@ -6013,14 +6351,6 @@
"required": true,
"name": "dateRange",
"in": "query"
},
{
"schema": {
"type": "boolean"
},
"required": false,
"name": "normalize",
"in": "query"
}
],
"responses": {
@@ -6039,10 +6369,10 @@
"type": "string"
},
"data": {
"nullable": true
"$ref": "#/components/schemas/UptimeDetails"
}
},
"required": ["success", "msg"]
"required": ["success", "msg", "data"]
}
}
}
@@ -10538,7 +10868,7 @@
},
"theme": {
"type": "string",
"enum": ["refined", "modern", "bold", "editorial"]
"enum": ["refined", "modern", "bold", "editorial", "minimal"]
},
"themeMode": {
"type": "string",
@@ -10756,7 +11086,7 @@
},
"theme": {
"type": "string",
"enum": ["refined", "modern", "bold", "editorial"]
"enum": ["refined", "modern", "bold", "editorial", "minimal"]
},
"themeMode": {
"type": "string",
+4 -1
View File
@@ -16,6 +16,7 @@ import {
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
monitorResponseSchema,
uptimeDetailsResponseSchema,
} from "@/api/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
@@ -81,6 +82,8 @@ registry.registerPath({
responses: { "200": okJson(z.array(z.string())), ...standardErrors },
});
const uptimeDetailsObject = uptimeDetailsResponseSchema.openapi("UptimeDetails");
registry.registerPath({
method: "get",
path: "/monitors/uptime/details/{monitorId}",
@@ -88,7 +91,7 @@ registry.registerPath({
summary: "Get uptime details for a monitor",
security: bearer,
request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
responses: { "200": okJson(uptimeDetailsObject), ...standardErrors },
});
registry.registerPath({
+1
View File
@@ -15,6 +15,7 @@
"worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js",
"clean": "rm -rf dist",
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates",
"typeCheck": "tsc --noEmit",
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
@@ -22,8 +22,6 @@ import {
} from "@/api/validation/userValidation.js";
import { IUserService } from "@/domain/users/user.service.js";
const SERVICE_NAME = "authController";
export interface IAuthController {
registerUser: RequestHandler;
createUser: RequestHandler;
@@ -42,18 +40,12 @@ export interface IAuthController {
}
class AuthController implements IAuthController {
static SERVICE_NAME = SERVICE_NAME;
private userService: IUserService;
constructor(userService: IUserService) {
this.userService = userService;
}
get serviceName() {
return AuthController.SERVICE_NAME;
}
registerUser = catchAsync(async (req: Request, res: Response) => {
const newUser = req.body.user;
const newUserToken = req.body.token;
@@ -11,10 +11,7 @@ import {
import { ICheckService } from "@/domain/checks/check.service.js";
import { requireTeamId } from "@/api/controllers/controllerUtils.js";
const SERVICE_NAME = "checkController";
export interface ICheckController {
serviceName: string;
getChecksByMonitor: RequestHandler;
getChecksByTeam: RequestHandler;
getChecksSummaryByTeamId: RequestHandler;
@@ -23,17 +20,11 @@ export interface ICheckController {
}
class CheckController implements ICheckController {
static SERVICE_NAME = SERVICE_NAME;
private checkService: ICheckService;
constructor(checkService: ICheckService) {
this.checkService = checkService;
}
get serviceName() {
return CheckController.SERVICE_NAME;
}
getChecksByMonitor = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
+1 -19
View File
@@ -1,5 +1,5 @@
import { AppError } from "@/utils/AppError.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import { UserRole } from "@/domain/users/user.type.js";
import sslChecker, { SSLDetails } from "ssl-checker";
type SSLCheckerType = typeof sslChecker;
@@ -54,24 +54,6 @@ export const optionalNumber = (value: unknown, fieldName: string): number | unde
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
};
export const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
throw new AppError({ message: `${fieldName} must be a boolean`, status: 400 });
};
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseSingle = (input: unknown): MonitorType => {
if (typeof input !== "string") {
@@ -2,25 +2,17 @@ import { IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js";
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "diagnosticController";
export interface IDiagnosticController {
getSystemStats: RequestHandler;
}
class DiagnosticController implements IDiagnosticController {
static SERVICE_NAME = SERVICE_NAME;
private diagnosticService: IDiagnosticService;
constructor(diagnosticService: IDiagnosticService) {
this.diagnosticService = diagnosticService;
}
get serviceName() {
return DiagnosticController.SERVICE_NAME;
}
getSystemStats = catchAsync(async (req: Request, res: Response) => {
const diagnostics = await this.diagnosticService.getSystemStats();
return res.status(200).json({
@@ -4,23 +4,15 @@ import { getChecksParamValidation, getChecksQueryValidation } from "@/api/valida
import type { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
import { requireTeamId } from "./controllerUtils.js";
const SERVICE_NAME = "geoCheckController";
export interface IGeoCheckController {
getGeoChecksByMonitor: RequestHandler;
}
class GeoCheckController implements IGeoCheckController {
static SERVICE_NAME = SERVICE_NAME;
private geoChecksService: IGeoChecksService;
constructor(geoChecksService: IGeoChecksService) {
this.geoChecksService = geoChecksService;
}
get serviceName() {
return GeoCheckController.SERVICE_NAME;
}
getGeoChecksByMonitor = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
@@ -3,7 +3,6 @@ import { catchAsync } from "@/utils/catchAsync.js";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/api/validation/authValidation.js";
import { requireFirstName, requireTeamId, requireUserRoles } from "@/api/controllers/controllerUtils.js";
import { IInviteService } from "@/domain/invites/invite.service.js";
const SERVICE_NAME = "inviteController";
export interface IInviteController {
getInviteToken: RequestHandler;
@@ -12,16 +11,11 @@ export interface IInviteController {
}
class InviteController implements IInviteController {
static SERVICE_NAME = SERVICE_NAME;
private inviteService: IInviteService;
constructor(inviteService: IInviteService) {
this.inviteService = inviteService;
}
get serviceName() {
return InviteController.SERVICE_NAME;
}
getInviteToken = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const userRoles = requireUserRoles(req.user?.role);
@@ -2,21 +2,15 @@ import { ILogger } from "@/utils/logger.js";
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "LogController";
export interface ILogController {
getLogs: RequestHandler;
}
class LogController {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
get serviceName() {
return LogController.SERVICE_NAME;
}
getLogs = catchAsync(async (req: Request, res: Response) => {
const logs = this.logger.getLogs();
@@ -23,8 +23,6 @@ import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/domain/monitors/monitor.service.js";
import { INotificationsService } from "@/domain/notifications/notification.service.js";
const SERVICE_NAME = "monitorController";
export interface IMonitorController {
getMonitorCertificate: RequestHandler;
getUptimeDetailsById: RequestHandler;
@@ -47,8 +45,6 @@ export interface IMonitorController {
updateNotifications: RequestHandler;
}
class MonitorController implements IMonitorController {
static SERVICE_NAME = SERVICE_NAME;
private monitorService: IMonitorService;
private notificationsService: INotificationsService;
@@ -57,10 +53,6 @@ class MonitorController implements IMonitorController {
this.notificationsService = notificationsService;
}
get serviceName() {
return MonitorController.SERVICE_NAME;
}
getMonitorCertificate = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getCertificateParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
@@ -3,8 +3,6 @@ import { getQueueJobsQueryValidation } from "@/api/validation/queueValidation.js
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "JobQueueController";
export interface IJobQueueController {
getMetrics: RequestHandler;
getJobs: RequestHandler;
@@ -13,13 +11,8 @@ export interface IJobQueueController {
}
class JobQueueController implements IJobQueueController {
static SERVICE_NAME = SERVICE_NAME;
constructor(private scheduler: IJobScheduler) {}
get serviceName() {
return JobQueueController.SERVICE_NAME;
}
getMetrics = catchAsync(async (req: Request, res: Response) => {
const metrics = await this.scheduler.getMetrics();
res.status(200).json({
@@ -7,17 +7,13 @@ import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"
import { IEmailService } from "@/service/emailService.js";
import { Settings } from "@/domain/app-settings/app-settings.type.js";
const SERVICE_NAME = "SettingsController";
export interface ISettingsController {
serviceName: string;
getAppSettings: RequestHandler;
updateAppSettings: RequestHandler;
sendTestEmail: RequestHandler;
}
class SettingsController implements ISettingsController {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: ISettingsService;
private emailService: IEmailService;
constructor(settingsService: ISettingsService, emailService: IEmailService) {
@@ -25,10 +21,6 @@ class SettingsController implements ISettingsController {
this.emailService = emailService;
}
get serviceName() {
return SettingsController.SERVICE_NAME;
}
buildAppSettings = (dbSettings: Settings) => {
const sanitizedSettings: Record<string, unknown> = { ...dbSettings };
delete sanitizedSettings.version;
@@ -13,10 +13,7 @@ import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.
import { IStatusPageService } from "@/domain/status-pages/status-page.service.js";
import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js";
const SERVICE_NAME = "statusPageController";
export interface IStatusPageController {
readonly serviceName: string;
createStatusPage: RequestHandler;
updateStatusPage: RequestHandler;
getStatusPageByUrl: RequestHandler;
@@ -26,16 +23,11 @@ export interface IStatusPageController {
}
class StatusPageController implements IStatusPageController {
static SERVICE_NAME = SERVICE_NAME;
private statusPageService: IStatusPageService;
constructor(statusPageService: IStatusPageService) {
this.statusPageService = statusPageService;
}
get serviceName() {
return StatusPageController.SERVICE_NAME;
}
createStatusPage = catchAsync(async (req: Request, res: Response) => {
createStatusPageBodyValidation.parse(req.body);
if (req.file) {
+9 -8
View File
@@ -1,7 +1,8 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js";
import { MonitorTypes } from "@/domain/monitors/monitor.types.js";
import { MonitorTypes } from "@/domain/monitors/monitor.type.js";
import { CheckFilters, DateRanges, SortOrders } from "@/types/query.js";
//****************************************
// Check Validations
@@ -13,10 +14,10 @@ export const getChecksParamValidation = z.object({
export const getChecksQueryValidation = z.object({
type: z.enum(MonitorTypes).optional(),
sortOrder: z.enum(["asc", "desc"]),
sortOrder: z.enum(SortOrders),
limit: z.coerce.number().optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
filter: z.enum(["all", "up", "down", "resolve"]).optional(),
dateRange: z.enum(DateRanges),
filter: z.enum(CheckFilters).optional(),
ack: booleanCoercion.optional(),
page: z.coerce.number(),
rowsPerPage: z.coerce.number(),
@@ -25,10 +26,10 @@ export const getChecksQueryValidation = z.object({
});
export const getTeamChecksQueryValidation = z.object({
sortOrder: z.enum(["asc", "desc"]),
sortOrder: z.enum(SortOrders),
limit: z.coerce.number().optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
filter: z.enum(["all", "up", "down", "resolve"]).optional(),
dateRange: z.enum(DateRanges),
filter: z.enum(CheckFilters).optional(),
ack: booleanCoercion.optional(),
page: z.coerce.number(),
rowsPerPage: z.coerce.number(),
@@ -39,5 +40,5 @@ export const deleteChecksParamValidation = z.object({
});
export const getChecksSummaryByTeamIdQueryValidation = z.object({
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
dateRange: z.enum(DateRanges).optional(),
});
@@ -1,18 +1,20 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { DateRanges, SortOrders } from "@/types/query.js";
import { IncidentResolutionTypes } from "@/domain/incidents/incident.type.js";
//****************************************
// Incident Validations
//****************************************
export const getIncidentsByTeamQueryValidation = z.object({
sortOrder: z.enum(["asc", "desc"]),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).default("all"),
sortOrder: z.enum(SortOrders),
dateRange: z.enum(DateRanges).default("all"),
page: z.coerce.number().int().min(0),
rowsPerPage: z.coerce.number().int().min(1),
status: booleanCoercion.optional(),
monitorId: z.string().optional(),
resolutionType: z.enum(["manual", "automatic"]).optional(),
resolutionType: z.enum(IncidentResolutionTypes).optional(),
});
export const getIncidentSummaryQueryValidation = z.object({
@@ -1,6 +1,7 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { DurationUnits } from "@/domain/maintenance-windows/maintenance-window.type.js";
import { SortOrders } from "@/types/query.js";
const dateToString = z.coerce.date().transform((d) => d.toISOString());
@@ -49,7 +50,7 @@ export const getMaintenanceWindowsByTeamIdQueryValidation = z.object({
page: z.coerce.number().optional(),
rowsPerPage: z.coerce.number().optional(),
field: z.string().optional(),
order: z.enum(["asc", "desc"]).optional(),
order: z.enum(SortOrders).optional(),
});
export const getMaintenanceWindowsByMonitorIdParamValidation = z.object({
+57 -9
View File
@@ -10,7 +10,8 @@ import {
MonitorStatuses,
MonitorTypes,
PageSpeedStrategies,
} from "@/domain/monitors/monitor.types.js";
} from "@/domain/monitors/monitor.type.js";
import { DateRanges, SortOrders } from "@/types/query.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -20,11 +21,10 @@ export const getMonitorByIdParamValidation = z.object({
export const getMonitorByIdQueryValidation = z.object({
status: booleanCoercion.optional(),
sortOrder: z.enum(["asc", "desc"]).optional(),
sortOrder: z.enum(SortOrders).optional(),
limit: z.coerce.number().optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
dateRange: z.enum(DateRanges).optional(),
numToDisplay: z.coerce.number().optional(),
normalize: booleanCoercion.optional(),
continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(),
});
@@ -42,7 +42,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({
rowsPerPage: z.coerce.number().int().min(1).max(100).optional(),
filter: z.union([z.string(), z.literal("")]).optional(),
field: z.string().optional(),
order: z.enum(["asc", "desc"]).optional(),
order: z.enum(SortOrders).optional(),
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
tags: z.union([z.string(), z.array(z.string())]).optional(),
explain: booleanCoercion.optional(),
@@ -195,8 +195,7 @@ export const getUptimeDetailsByIdParamValidation = z.object({
});
export const getUptimeDetailsByIdQueryValidation = z.object({
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
normalize: booleanCoercion.optional(),
dateRange: z.enum(DateRanges),
});
const importedMonitorSchema = z
@@ -206,7 +205,7 @@ const importedMonitorSchema = z
teamId: z.string().optional(),
name: z.string().min(1, "Name is required"),
description: z.union([z.string(), z.literal("")]).optional(),
status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
status: z.enum(MonitorStatuses).default("initializing"),
statusWindow: z.array(z.boolean()).default([]),
statusWindowSize: z.number().min(1).max(20).default(5),
statusWindowThreshold: z.number().min(1).max(100).default(60),
@@ -263,7 +262,7 @@ export const getHardwareDetailsByIdParamValidation = z.object({
});
export const getHardwareDetailsByIdQueryValidation = z.object({
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
dateRange: z.enum(DateRanges).optional(),
});
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with
@@ -311,3 +310,52 @@ export const monitorResponseSchema = z
lastEvaluatedAt: z.number(),
})
.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(),
});
@@ -3,6 +3,7 @@ import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { cssReferencesExternalResource } from "@/utils/customCss.js";
import { ImageMimeTypes } from "@/types/upload.js";
//****************************************
// Status Page Validations
@@ -67,7 +68,7 @@ export const imageValidation = z
fieldname: z.string().min(1, "Field name is required"),
originalname: z.string().min(1, "Original name is required"),
encoding: z.string().min(1, "Encoding is required"),
mimetype: z.enum(["image/jpeg", "image/png", "image/jpg"], {
mimetype: z.enum(ImageMimeTypes, {
message: "File must be a valid image (jpeg, jpg, or png)",
}),
size: z.number().max(3145728, "File size must be less than 3MB"),
+2 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { UserRoles } from "@/domain/users/user.type.js";
import { nameValidation, lowercaseEmailValidation, passwordValidation } from "./shared.js";
import { nameValidation, lowercaseEmailValidation, passwordValidation, booleanCoercion } from "@/api/validation/shared.js";
//****************************************
// User Validations
@@ -46,6 +46,7 @@ export const editUserBodyValidation = z
profilePicture: z.string().optional(),
password: passwordValidation.optional(),
newPassword: passwordValidation.optional(),
deleteProfileImage: booleanCoercion.optional(),
})
// Changing a password requires both the current password and the new one.
// The service only runs the change-password flow when both are present, so
+20 -15
View File
@@ -21,6 +21,7 @@ import { GlobalPingService } from "@/service/globalPingService.js";
import { ILogger } from "@/utils/logger.js";
// Notification providers
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
import { WebhookProvider } from "@/domain/notifications/providers/webhook.js";
import { SlackProvider } from "@/domain/notifications/providers/slack.js";
import { EmailProvider } from "@/domain/notifications/providers/email.js";
@@ -125,7 +126,7 @@ export const buildShared = async ({
const checksRepository = new MongoChecksRepository(logger);
const incidentsRepository = new MongoIncidentsRepository();
const usersRepository = new MongoUsersRepository();
const geoChecksRepository = new MongoGeoChecksRepository(logger);
const geoChecksRepository = new MongoGeoChecksRepository();
const notificationsRepository = new MongoNotificationsRepository();
const jobsRepository = new MongoJobsRepository(workerId);
const queueWorkersRepository = new MongoQueueWorkersRepository();
@@ -167,24 +168,28 @@ export const buildShared = async ({
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
const notificationsService = new NotificationsService(
const notificationProviders: NotificationProviderRegistry = {
webhook: webhookProvider,
email: emailProvider,
slack: slackProvider,
discord: discordProvider,
pager_duty: pagerDutyProvider,
matrix: matrixProvider,
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
const notificationsService = new NotificationsService({
notificationsRepository,
monitorsRepository,
webhookProvider,
emailProvider,
slackProvider,
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
ntfyProvider,
providers: notificationProviders,
settingsService,
logger,
notificationMessageBuilder
);
notificationMessageBuilder,
});
const sharedServices: SharedServices = {
logger,
+8 -8
View File
@@ -131,9 +131,9 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
geoChecksRepository
);
const worker = await DBQueueWorker.create(
const worker = await DBQueueWorker.create({
logger,
() => mongoose.connection.readyState === 1,
isDbConnected: () => mongoose.connection.readyState === 1,
jobsRepository,
monitorsRepository,
checksRepository,
@@ -142,13 +142,13 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
checkProducer,
checkEvaluator,
geoCheckPipeline,
reactorDispatcher,
workerHelper,
dispatcher: reactorDispatcher,
helper: workerHelper,
queueWorkersRepository,
envSettings.queueMode,
envSettings.queuePrimaryProcesses,
workerId
);
queueMode: envSettings.queueMode,
queuePrimaryProcesses: envSettings.queuePrimaryProcesses,
workerId,
});
return { worker, networkService, bufferService, statusService };
};
-4
View File
@@ -21,10 +21,6 @@ class MongoDB implements IDb<Mongoose> {
this.db = null;
}
get serviceName() {
return MongoDB.SERVICE_NAME;
}
connect = async () => {
try {
const connectionString = this.envSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db";
@@ -0,0 +1,209 @@
import mongoose from "mongoose";
import type { Document } from "mongodb";
import type { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "Migration:RecomputeResponseTimeFromTimings";
const CHECKS_COLLECTION = "checks";
const BACKUP_COLLECTION = "checks_backup_response_time";
const FAILED_DOCS_COLLECTION = "checks_response_time_migration_failed";
const BATCH_SIZE = 1000;
const NAMESPACE_EXISTS_CODE = 48;
interface MigrationStats {
totalSource: number;
totalMigrated: number;
totalFailed: number;
}
const getDb = () => {
const db = mongoose.connection.db;
if (!db) {
throw new Error("Database connection is not initialized");
}
return db;
};
// `checks` is a time-series collection (0002): measurement fields cannot be updated in place,
// so recomputing responseTime requires a backup → drop → recreate → re-insert rewrite.
const createTimeSeriesCollection = async () => {
const db = getDb();
const existing = await db.listCollections({ name: CHECKS_COLLECTION }).toArray();
if (existing.length === 0) {
try {
await db.createCollection(CHECKS_COLLECTION, {
timeseries: {
timeField: "createdAt",
metaField: "metadata",
granularity: "seconds",
},
});
} catch (error) {
// Mongoose's autoCreate can create `checks` concurrently with this migration
if ((error as { code?: number })?.code !== NAMESPACE_EXISTS_CODE) {
throw error;
}
}
}
await db
.collection(CHECKS_COLLECTION)
.createIndexes([
{ key: { createdAt: 1 } },
{ key: { "metadata.monitorId": 1, createdAt: 1 } },
{ key: { "metadata.monitorId": 1, createdAt: -1 } },
{ key: { "metadata.teamId": 1, createdAt: -1 } },
{ key: { "metadata.monitorId": 1, "metadata.type": 1, createdAt: -1 } },
{ key: { "metadata.teamId": 1, status: 1, createdAt: -1 } },
]);
};
const stripId = (doc: Document): Document => {
const { _id, ...rest } = doc;
void _id;
return rest;
};
const recomputeResponseTime = (doc: Document): Document => {
const rest = stripId(doc);
const total = doc.timings?.phases?.total;
if (typeof total === "number") {
rest.responseTime = total;
}
return rest;
};
const storeFailedDocument = async (doc: Document, reason: string) => {
const db = getDb();
await db.collection(FAILED_DOCS_COLLECTION).insertOne({
originalDoc: doc,
reason,
failedAt: new Date(),
});
};
const streamBackupIntoChecks = async (transform: (doc: Document) => Document, collectFailures: boolean): Promise<MigrationStats> => {
const db = getDb();
const source = db.collection(BACKUP_COLLECTION);
const target = db.collection(CHECKS_COLLECTION);
const stats: MigrationStats = {
totalSource: await source.countDocuments(),
totalMigrated: 0,
totalFailed: 0,
};
const cursor = source.find();
// Derive the op type from the exact bulkWrite we call so it matches whichever `mongodb` copy
// Mongoose resolves (see 0002 for the nested-node_modules background)
type BulkOp = Parameters<typeof target.bulkWrite>[0][number];
const operations: BulkOp[] = [];
const invalidDocs: Document[] = [];
while (await cursor.hasNext()) {
const doc = await cursor.next();
if (!doc) {
continue;
}
// Time-series inserts reject documents without the timeField
if (!doc.createdAt) {
invalidDocs.push(doc);
continue;
}
operations.push({ insertOne: { document: transform(doc) } });
if (operations.length >= BATCH_SIZE) {
const result = await target.bulkWrite(operations, { ordered: false });
stats.totalMigrated += result.insertedCount;
operations.length = 0;
}
}
if (operations.length) {
const result = await target.bulkWrite(operations, { ordered: false });
stats.totalMigrated += result.insertedCount;
}
if (collectFailures) {
for (const doc of invalidDocs) {
await storeFailedDocument(doc, "Missing createdAt (required timeField)");
stats.totalFailed++;
}
} else {
stats.totalFailed = invalidDocs.length;
}
await cursor.close();
return stats;
};
// Restore cannot be a simple rename like 0002's: $out produced a standard collection, and
// renaming it to `checks` would silently lose the time-series type. Rebuild and re-insert instead.
const restoreFromBackup = async () => {
const db = getDb();
const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray();
if (backupExists.length === 0) {
throw new Error("Cannot restore: backup collection does not exist");
}
const checksExists = await db.listCollections({ name: CHECKS_COLLECTION }).toArray();
if (checksExists.length > 0) {
await db.collection(CHECKS_COLLECTION).drop();
}
await createTimeSeriesCollection();
await streamBackupIntoChecks(stripId, false);
};
export async function recomputeResponseTimeFromTimings(logger: ILogger): Promise<void> {
const db = getDb();
const checksExists = await db.listCollections({ name: CHECKS_COLLECTION }).toArray();
if (checksExists.length === 0) {
logger.info({ service: SERVICE_NAME, message: "No checks collection, nothing to migrate" });
return;
}
// Fresh installs and non-HTTP-only deployments have no stored timings — skip the rewrite entirely
const needsMigration = await db.collection(CHECKS_COLLECTION).countDocuments({ "timings.phases.total": { $exists: true } }, { limit: 1 });
if (needsMigration === 0) {
logger.info({ service: SERVICE_NAME, message: "No checks with timings.phases.total, nothing to migrate" });
return;
}
const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray();
if (backupExists.length > 0) {
throw new Error(`Backup collection "${BACKUP_COLLECTION}" already exists. Please remove it manually before running migration.`);
}
await db
.collection(CHECKS_COLLECTION)
.aggregate([{ $match: {} }, { $out: BACKUP_COLLECTION }])
.toArray();
await db.collection(CHECKS_COLLECTION).drop();
try {
await createTimeSeriesCollection();
const stats = await streamBackupIntoChecks(recomputeResponseTime, true);
logger.info({
service: SERVICE_NAME,
message:
`Migrated ${stats.totalMigrated}/${stats.totalSource} checks, ${stats.totalFailed} failed ` +
`(failures stored in ${FAILED_DOCS_COLLECTION}); backup retained in ${BACKUP_COLLECTION}`,
});
} catch (error) {
try {
await restoreFromBackup();
logger.error({ service: SERVICE_NAME, message: "Migration failed, checks restored from backup" });
} catch (restoreError) {
const restoreMessage = restoreError instanceof Error ? restoreError.message : String(restoreError);
logger.error({
service: SERVICE_NAME,
message: `Migration failed AND rollback failed (${restoreMessage}) — original data is preserved in ${BACKUP_COLLECTION}`,
});
}
throw error;
}
}
+2
View File
@@ -7,6 +7,7 @@ import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArra
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js";
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -23,6 +24,7 @@ const migrations: MigrationEntry[] = [
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
{ name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray },
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
{ name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings },
];
const runMigrations = async (logger: ILogger) => {
@@ -20,8 +20,6 @@ export type EnvConfig = {
};
export interface ISettingsService {
readonly serviceName: string;
loadSettings(): EnvConfig;
getSettings(): EnvConfig;
areStatusPageThemesEnabled(): boolean;
getDBSettings(): Promise<Settings>;
@@ -57,14 +55,6 @@ export class SettingsService implements ISettingsService {
this.settingsRepository = settingsRepository;
}
get serviceName() {
return SettingsService.SERVICE_NAME;
}
loadSettings() {
return this.settings;
}
getSettings() {
return this.settings;
}
@@ -1,9 +1,22 @@
import { CheckModel } from "@/domain/checks/check.model.js";
import mongoose from "mongoose";
import type { HardwareDiskStats, HardwareNetStats } from "@/domain/checks/check.type.js";
type DateRange = { start: Date; end: Date };
type HardwareUpChecks = { totalChecks: number };
// $avg yields null when no values are present, so metrics are nullable until the repository applies fallbacks
type NullableValues<T> = { [K in keyof T]: T[K] | null };
export type HardwareStatsBucket = {
_id: string;
avgCpuUsage: number | null;
avgMemoryUsage: number | null;
avgTemperature: number[];
disks: NullableValues<HardwareDiskStats>[];
net: NullableValues<HardwareNetStats>[];
};
const NET_RATE_FIELDS = [
["bytesSentPerSecond", "bytes_sent"],
["deltaBytesRecv", "bytes_recv"],
@@ -70,13 +83,13 @@ export const getHardwareUpChecks = async (monitorId: string, dates: DateRange):
return { totalChecks: count };
};
export const getHardwareStats = async (monitorId: string, dates: DateRange, dateString: string) => {
export const getHardwareStats = async (monitorId: string, dates: DateRange, dateString: string): Promise<HardwareStatsBucket[]> => {
const netProjection = {
name: { $arrayElemAt: ["$sampleDoc.net.name", "$$nIdx"] },
...Object.fromEntries(NET_RATE_FIELDS.map(([outputKey, sourceField]) => [outputKey, netRatePerSecond(sourceField)])),
};
return await CheckModel.aggregate([
return await CheckModel.aggregate<HardwareStatsBucket>([
{
$match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+1 -1
View File
@@ -1,5 +1,5 @@
import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import type {
Check,
CheckAudits,
@@ -6,8 +6,9 @@ import type {
PageSpeedChecksResult,
UptimeChecksResult,
} from "@/domain/checks/check.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { LatestChecksMap } from "@/domain/checks/check.repository.mongo.js";
import { CheckFilter, DateRange } from "@/types/query.js";
export interface IChecksRepository {
// create
@@ -19,29 +20,27 @@ export interface IChecksRepository {
findByMonitorId(
monitorId: string,
sortOrder: string,
dateRange: string,
filter: string | undefined,
dateRange: DateRange,
page: number,
rowsPerPage: number,
status: boolean | undefined
status: boolean | undefined,
filter?: CheckFilter
): Promise<ChecksQueryResult>;
findByTeamId(
sortOrder: string,
dateRange: string,
filter: string | undefined,
dateRange: DateRange,
page: number,
rowsPerPage: number,
teamId: string
teamId: string,
filter?: CheckFilter
): Promise<ChecksQueryResult>;
findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>;
findByDateRangeAndMonitorId(
monitorId: string,
startDate: Date,
endDate: Date,
dateString: string,
dateRange: DateRange,
options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
findSummaryByTeamId(teamId: string, dateRange: string): Promise<ChecksSummary>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
// update
//delete
@@ -11,15 +11,19 @@ import type {
CheckMetadata,
CheckNetworkInterfaceInfo,
GotTimings,
HardwareCheckStats,
} from "@/domain/checks/check.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import { CheckModel, type CheckDocument } from "@/domain/checks/check.model.js";
import mongoose from "mongoose";
import { getDateForRange } from "@/utils/dataUtils.js";
import { getDateFormat, getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { getHardwareUpChecks, getHardwareStats, getHardwareTotalChecks } from "@/domain/checks/check.hardware.aggregations.js";
import { CheckFilter, DateRange } from "@/types/query.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
const SERVICE_NAME = "StatusService";
@@ -180,7 +184,12 @@ class MongoChecksRepository implements IChecksRepository {
// Map id to _id for MongoDB storage
const { id, metadata, ...rest } = check;
if (!metadata || !metadata.monitorId || !metadata.teamId) {
throw new Error(`Check must have valid metadata with monitorId and teamId. Got: ${JSON.stringify({ id, metadata })}`);
throw new AppError({
message: `Check must have valid metadata with monitorId and teamId. Got: ${JSON.stringify({ id, metadata })}`,
status: 500,
service: SERVICE_NAME,
method: "toDocument",
});
}
return {
_id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(),
@@ -204,50 +213,41 @@ class MongoChecksRepository implements IChecksRepository {
return this.mapDocuments(inserted as unknown as CheckDocument[]);
};
private filterToMatch = (filter: CheckFilter | undefined): Record<string, unknown> => {
switch (filter) {
case "up":
return { status: true };
case "down":
return { status: false };
case "resolve":
return { status: false, statusCode: NETWORK_ERROR };
default:
this.logger.warn({
message: "invalid filter",
service: SERVICE_NAME,
method: "filterToMatch",
});
return {};
}
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: string,
filter: string | undefined,
dateRange: DateRange,
page: number,
rowsPerPage: number,
status: boolean | undefined
status: boolean | undefined,
filter?: CheckFilter
) => {
// Match
const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(typeof status !== "undefined" && { status }),
...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
createdAt: { $gte: getDateForRange(dateRange) },
...this.filterToMatch(filter),
};
if (filter !== undefined) {
switch (filter) {
case "all":
break;
case "up":
matchStage.status = true;
break;
case "down":
matchStage.status = false;
break;
case "resolve":
matchStage.status = false;
matchStage.statusCode = 5000;
break;
default:
this.logger.warn({
message: "invalid filter",
service: SERVICE_NAME,
method: "getChecks",
});
break;
}
}
//Sort
const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
@@ -265,39 +265,12 @@ class MongoChecksRepository implements IChecksRepository {
return { checksCount, checks: this.mapDocuments(checks) };
};
findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
findByTeamId = async (sortOrder: string, dateRange: DateRange, page: number, rowsPerPage: number, teamId: string, filter?: CheckFilter) => {
const matchStage: Record<string, unknown> = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
createdAt: { $gte: getDateForRange(dateRange) },
...this.filterToMatch(filter),
};
// Add filter to match stage
if (filter !== undefined) {
switch (filter) {
case "all":
break;
case "up":
matchStage.status = true;
break;
case "down":
matchStage.status = false;
break;
case "resolve":
matchStage.status = false;
matchStage.statusCode = 5000;
break;
default:
this.logger.warn({
message: "invalid filter",
service: SERVICE_NAME,
method: "getChecksByTeam",
});
break;
}
}
const parsedSortOrder = sortOrder === "asc" ? 1 : -1;
@@ -341,25 +314,25 @@ class MongoChecksRepository implements IChecksRepository {
return mapped;
};
findByDateRangeAndMonitorId = async (monitorId: string, startDate: Date, endDate: Date, dateString: string, options?: { type?: MonitorType }) => {
findByDateRangeAndMonitorId = async (monitorId: string, dateRange: DateRange, options?: { type?: MonitorType }) => {
const monitorObjectId = new mongoose.Types.ObjectId(monitorId);
const start = getDateForRange(dateRange);
const dateString = getDateFormat(dateRange);
const end = new Date();
if (options?.type === "hardware") {
return this.findHardwareDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
return this.findHardwareDateRangeChecks(monitorObjectId, start, end, dateString);
}
if (options?.type === "pagespeed") {
return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
return this.findPageSpeedDateRangeChecks(monitorObjectId, start, end, dateString);
}
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, startDate, endDate, dateString);
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString);
};
findSummaryByTeamId = async (teamId: string, dateRange: string) => {
findSummaryByTeamId = async (teamId: string, dateRange: DateRange) => {
const baseMatch = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
createdAt: { $gte: getDateForRange(dateRange) },
};
const [totalResult, downResult] = await Promise.all([
@@ -472,11 +445,30 @@ class MongoChecksRepository implements IChecksRepository {
$dateToString: { format: dateString, date: "$createdAt" },
},
avgResponseTime: { $avg: "$responseTime" },
avgDns: { $avg: { $ifNull: ["$timings.phases.dns", 0] } },
avgTcp: { $avg: { $ifNull: ["$timings.phases.tcp", 0] } },
avgTls: { $avg: { $ifNull: ["$timings.phases.tls", 0] } },
avgRequest: { $avg: { $ifNull: ["$timings.phases.request", 0] } },
avgFirstByte: { $avg: { $ifNull: ["$timings.phases.firstByte", 0] } },
avgDownload: { $avg: { $ifNull: ["$timings.phases.download", 0] } },
totalChecks: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
{
$project: {
bucketDate: "$_id",
avgResponseTime: 1,
totalChecks: 1,
avgDns: 1,
avgTcp: 1,
avgTls: 1,
avgRequest: 1,
avgFirstByte: 1,
avgDownload: 1,
_id: 0,
},
},
],
groupedUpChecks: [
{ $match: { status: true } },
@@ -540,33 +532,35 @@ class MongoChecksRepository implements IChecksRepository {
totalChecks: upChecksDoc?.totalChecks ?? 0,
};
const checks = (hardwareMetrics ?? []).map((metric) => ({
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk: { [key: string]: number | string | undefined }) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface: { [key: string]: number | string | undefined }) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
}));
const checks = (hardwareMetrics ?? []).map(
(metric): HardwareCheckStats => ({
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
})
);
return {
monitorType: "hardware" as const,
+14 -17
View File
@@ -6,6 +6,7 @@ import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.j
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { CheckFilter, DateRange } from "@/types/query.js";
const SERVICE_NAME = "checkService";
@@ -18,21 +19,21 @@ export interface ICheckService {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
dateRange: DateRange;
page: number;
rowsPerPage: number;
filter?: string;
filter?: CheckFilter;
status?: boolean;
}): Promise<ChecksQueryResult>;
getChecksByTeam(params: {
teamId: string;
sortOrder: string;
dateRange: string;
dateRange: DateRange;
page: number;
rowsPerPage: number;
filter?: string;
filter?: CheckFilter;
}): Promise<ChecksQueryResult>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: DateRange }): Promise<ChecksSummary>;
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
@@ -50,10 +51,6 @@ export class CheckService implements ICheckService {
this.checksRepository = checksRepository;
}
get serviceName() {
return CheckService.SERVICE_NAME;
}
createChecks = async (checks: Check[]) => {
return this.checksRepository.createChecks(checks);
};
@@ -168,11 +165,11 @@ export class CheckService implements ICheckService {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
dateRange: DateRange;
page: number;
rowsPerPage: number;
status?: boolean;
filter?: string;
filter: CheckFilter;
}) => {
if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
@@ -187,7 +184,7 @@ export class CheckService implements ICheckService {
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5;
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, status);
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, status, filter);
return result;
};
@@ -196,25 +193,25 @@ export class CheckService implements ICheckService {
teamId,
sortOrder,
dateRange,
filter,
page,
rowsPerPage,
filter,
}: {
teamId: string;
sortOrder: string;
dateRange: string;
dateRange: DateRange;
page: number;
rowsPerPage: number;
filter?: string;
filter?: CheckFilter;
}) => {
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5;
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId);
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, parsedPage, parsedRowsPerPage, teamId, filter);
return checkData;
};
getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: string }) => {
getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: DateRange }) => {
const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange);
return summary;
};
+57 -47
View File
@@ -1,4 +1,4 @@
import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { Response } from "got";
export const CHECK_TTL_SENTINEL = 366;
@@ -126,49 +126,21 @@ export interface PageSpeedChecksResult {
groupedChecks: PageSpeedGroupedCheck[];
}
export interface HardwareChecksResult {
monitorType: "hardware";
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: Array<{
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: Array<{
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}>;
net: Array<{
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}>;
}>;
}
export interface GroupedCheck {
bucketDate: string;
avgResponseTime: number;
totalChecks: number;
}
export interface GroupedUptimeCheck extends GroupedCheck {
avgDns: number;
avgTcp: number;
avgTls: number;
avgRequest: number;
avgFirstByte: number;
avgDownload: number;
}
export interface PageSpeedGroupedCheck {
bucketDate: string;
performance: number;
@@ -180,7 +152,7 @@ export interface PageSpeedGroupedCheck {
export interface UptimeChecksResult {
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
groupedChecks: GroupedCheck[];
groupedChecks: GroupedUptimeCheck[];
groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[];
uptimePercentage: number;
@@ -196,12 +168,50 @@ export interface HasResponseTime {
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 interface HardwareDiskStats {
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}
export interface HardwareNetStats {
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}
export interface HardwareCheckStats {
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: HardwareDiskStats[];
net: HardwareNetStats[];
}
export interface HardwareStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: HardwareCheckStats[];
}
export interface HardwareChecksResult extends HardwareStats {
monitorType: "hardware";
}
@@ -79,10 +79,6 @@ export class DiagnosticService implements IDiagnosticService {
obs.observe({ entryTypes: ["measure"] });
}
get serviceName() {
return DiagnosticService.SERVICE_NAME;
}
private getCPUUsagePercentage = async (): Promise<number> => {
const startUsage = process.cpuUsage();
const timingPeriod = 1000; // measured in ms
@@ -1,5 +1,5 @@
import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/domain/geo-checks/geo-check.type.js";
type GeoCheckMetadataDocument = Omit<GeoCheckMetadata, "monitorId" | "teamId"> & {
@@ -1,5 +1,6 @@
import type { GeoCheck, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import type { GeoContinent, FlatGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import { DateRange } from "@/types/query.js";
export interface GeoChecksQueryResult {
geoChecksCount: number;
@@ -16,19 +17,12 @@ export interface IGeoChecksRepository {
findByMonitorId(
monitorId: string,
sortOrder: string,
dateRange: string,
dateRange: DateRange,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult>;
findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]>;
findGroupedByMonitorIdAndDateRange(
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]>;
findGroupedByMonitorIdAndDateRange(monitorId: string, DateRange: DateRange, continents?: GeoContinent[]): Promise<GroupedGeoCheck[]>;
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
@@ -3,20 +3,15 @@ import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoCo
import type { FlatGeoChecksQueryResult } from "./geo-check.repository.interface.js";
import { GeoCheckMetadataDocument, GeoCheckModel, type GeoCheckDocument } from "@/domain/geo-checks/geo-check.model.js";
import mongoose, { PipelineStage } from "mongoose";
import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
import { getDateForRange, getDateFormat } from "@/utils/dataUtils.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "GeoChecksRepository";
class MongoGeoChecksRepository implements IGeoChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
private toEntity = (doc: GeoCheckDocument): GeoCheck => {
const mapMetadata = (metadata: GeoCheckMetadataDocument): GeoCheckMetadata => ({
monitorId: toStringId(metadata.monitorId),
@@ -63,304 +58,214 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
};
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
try {
const docs = await GeoCheckModel.insertMany(
geoChecks.map((geoCheck) => ({
metadata: {
monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId),
teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId),
type: geoCheck.metadata.type,
},
results: geoCheck.results,
expiry: new Date(geoCheck.expiry),
}))
);
return docs.map((doc) => this.toEntity(doc));
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Failed to createGeoChecks: ${error.message}` : "Failed to createGeoChecks",
service: SERVICE_NAME,
method: "createGeoChecks",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
const docs = await GeoCheckModel.insertMany(
geoChecks.map((geoCheck) => ({
metadata: {
monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId),
teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId),
type: geoCheck.metadata.type,
},
results: geoCheck.results,
expiry: new Date(geoCheck.expiry),
}))
);
return docs.map((doc) => this.toEntity(doc));
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: string,
dateRange: DateRange,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult> => {
try {
const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(getDateForRange(dateRange) && {
const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: { $gte: getDateForRange(dateRange) },
};
const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
// Common pipeline stages for both paths
const pipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
// Project to flat structure
{
$project: {
_id: 0,
monitorId: "$metadata.monitorId",
teamId: "$metadata.teamId",
type: "$metadata.type",
location: "$results.location",
status: "$results.status",
statusCode: "$results.statusCode",
timings: "$results.timings",
createdAt: 1,
updatedAt: 1,
},
},
{ $sort: { createdAt: convertedSortOrder } },
{ $skip: skip },
{ $limit: rowsPerPage },
];
// Count pipeline
const countPipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
{ $count: "count" },
];
const [countResult, dataResults] = await Promise.all([GeoCheckModel.aggregate(countPipeline), GeoCheckModel.aggregate(pipeline)]);
const geoChecksCount = countResult[0]?.count || 0;
const geoChecks: FlatGeoCheck[] = dataResults.map((doc) => ({
id: `${doc.monitorId.toString()}-${new Date(doc.createdAt).getTime()}-${doc.location.continent}-${doc.location.city}-${Math.random().toString(36).substring(2, 15)}`,
monitorId: doc.monitorId.toString(),
teamId: doc.teamId.toString(),
type: doc.type,
location: doc.location,
status: doc.status,
statusCode: doc.statusCode,
timings: doc.timings,
createdAt: new Date(doc.createdAt).toISOString(),
updatedAt: new Date(doc.updatedAt).toISOString(),
}));
return { geoChecksCount, geoChecks };
};
findGroupedByMonitorIdAndDateRange = async (monitorId: string, dateRange: DateRange, continents?: GeoContinent[]): Promise<GroupedGeoCheck[]> => {
const start = getDateForRange(dateRange);
const dateString = getDateFormat(dateRange);
const end = new Date();
const pipeline: PipelineStage[] = [
// Match geo checks for this monitor in date range
{
$match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: getDateForRange(dateRange),
$gte: start,
$lte: end,
},
}),
};
const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
// Common pipeline stages for both paths
const pipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
// Filter by continent if specified
...(continents && continents.length > 0
? [
},
},
// Unwind the results array to process each location separately
{
$unwind: "$results",
},
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
// Group by date bucket and continent
{
$group: {
_id: {
bucketDate: {
$dateToString: {
format: dateString,
date: "$createdAt",
},
},
continent: "$results.location.continent",
},
avgResponseTime: { $avg: "$results.timings.total" },
totalChecks: { $sum: 1 },
upChecks: {
$sum: {
$cond: ["$results.status", 1, 0],
},
},
},
},
// Calculate uptime percentage
{
$project: {
_id: 0,
bucketDate: "$_id.bucketDate",
continent: "$_id.continent",
avgResponseTime: { $round: ["$avgResponseTime", 2] },
totalChecks: 1,
uptimePercentage: {
$round: [
{
$match: {
"results.location.continent": { $in: continents },
},
$multiply: [
{
$divide: ["$upChecks", "$totalChecks"],
},
100,
],
},
]
: []),
// Project to flat structure
{
$project: {
_id: 0,
monitorId: "$metadata.monitorId",
teamId: "$metadata.teamId",
type: "$metadata.type",
location: "$results.location",
status: "$results.status",
statusCode: "$results.statusCode",
timings: "$results.timings",
createdAt: 1,
updatedAt: 1,
2,
],
},
},
{ $sort: { createdAt: convertedSortOrder } },
{ $skip: skip },
{ $limit: rowsPerPage },
];
// Count pipeline
const countPipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
{ $count: "count" },
];
const [countResult, dataResults] = await Promise.all([GeoCheckModel.aggregate(countPipeline), GeoCheckModel.aggregate(pipeline)]);
const geoChecksCount = countResult[0]?.count || 0;
const geoChecks: FlatGeoCheck[] = dataResults.map((doc) => ({
id: `${doc.monitorId.toString()}-${new Date(doc.createdAt).getTime()}-${doc.location.continent}-${doc.location.city}-${Math.random().toString(36).substring(2, 15)}`,
monitorId: doc.monitorId.toString(),
teamId: doc.teamId.toString(),
type: doc.type,
location: doc.location,
status: doc.status,
statusCode: doc.statusCode,
timings: doc.timings,
createdAt: new Date(doc.createdAt).toISOString(),
updatedAt: new Date(doc.updatedAt).toISOString(),
}));
return { geoChecksCount, geoChecks };
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error finding geo checks by monitor ID: ${error.message}` : "Error finding geo checks by monitor ID",
service: SERVICE_NAME,
method: "findByMonitorId",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
try {
const docs = await GeoCheckModel.find({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: startDate,
$lte: endDate,
},
// Sort by date and continent
{
$sort: {
bucketDate: 1,
continent: 1,
},
}).sort({ createdAt: -1 });
return docs.map(this.toEntity);
} catch (error: unknown) {
this.logger.error({
message:
error instanceof Error
? `Error finding geo checks by monitor ID and date range: ${error.message}`
: "Error finding geo checks by monitor ID and date range",
service: SERVICE_NAME,
method: "findByMonitorIdAndDateRange",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
},
];
findGroupedByMonitorIdAndDateRange = async (
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]> => {
try {
const pipeline: PipelineStage[] = [
// Match geo checks for this monitor in date range
{
$match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: startDate,
$lte: endDate,
},
},
},
// Unwind the results array to process each location separately
{
$unwind: "$results",
},
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
// Group by date bucket and continent
{
$group: {
_id: {
bucketDate: {
$dateToString: {
format: dateFormat,
date: "$createdAt",
},
},
continent: "$results.location.continent",
},
avgResponseTime: { $avg: "$results.timings.total" },
totalChecks: { $sum: 1 },
upChecks: {
$sum: {
$cond: ["$results.status", 1, 0],
},
},
},
},
// Calculate uptime percentage
{
$project: {
_id: 0,
bucketDate: "$_id.bucketDate",
continent: "$_id.continent",
avgResponseTime: { $round: ["$avgResponseTime", 2] },
totalChecks: 1,
uptimePercentage: {
$round: [
{
$multiply: [
{
$divide: ["$upChecks", "$totalChecks"],
},
100,
],
},
2,
],
},
},
},
// Sort by date and continent
{
$sort: {
bucketDate: 1,
continent: 1,
},
},
];
const results = await GeoCheckModel.aggregate(pipeline);
return results as GroupedGeoCheck[];
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error finding grouped geo checks: ${error.message}` : "Error finding grouped geo checks",
service: SERVICE_NAME,
method: "findGroupedByMonitorIdAndDateRange",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
const results = await GeoCheckModel.aggregate(pipeline);
return results as GroupedGeoCheck[];
};
deleteByMonitorId = async (monitorId: string): Promise<number> => {
try {
const result = await GeoCheckModel.deleteMany({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
});
return result.deletedCount || 0;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error deleting geo checks by monitor ID: ${error.message}` : "Error deleting geo checks by monitor ID",
service: SERVICE_NAME,
method: "deleteByMonitorId",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
const result = await GeoCheckModel.deleteMany({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
});
return result.deletedCount || 0;
};
deleteByTeamId = async (teamId: string): Promise<number> => {
try {
const result = await GeoCheckModel.deleteMany({
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
});
return result.deletedCount || 0;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error deleting geo checks by team ID: ${error.message}` : "Error deleting geo checks by team ID",
service: SERVICE_NAME,
method: "deleteByTeamId",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
const result = await GeoCheckModel.deleteMany({
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
});
return result.deletedCount || 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
try {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
return result.deletedCount || 0;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error deleting orphaned geo checks: ${error.message}` : "Error deleting orphaned geo checks",
service: SERVICE_NAME,
method: "deleteByMonitorIdsNotIn",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
return result.deletedCount || 0;
};
}
@@ -1,5 +1,5 @@
import type { GeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { GeoCheckResult, GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import { Types } from "mongoose";
import type { FlatGeoChecksQueryResult, IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
@@ -7,18 +7,18 @@ import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.i
import type { IGlobalPingService } from "@/service/globalPingService.js";
import type { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "GeoChecksService";
export interface IGeoChecksService {
readonly serviceName: string;
buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null>;
createGeoChecks(geoChecks: GeoCheck[]): Promise<GeoCheck[]>;
getGeoChecksByMonitor(args: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
dateRange: DateRange;
page?: number;
rowsPerPage?: number;
continent?: GeoContinent | GeoContinent[];
@@ -50,10 +50,6 @@ export class GeoChecksService implements IGeoChecksService {
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return GeoChecksService.SERVICE_NAME;
}
async buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null> {
try {
if (!monitor.url) {
@@ -160,7 +156,7 @@ export class GeoChecksService implements IGeoChecksService {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
dateRange: DateRange;
page?: number;
rowsPerPage?: number;
continent: GeoContinent | GeoContinent[];
@@ -1,4 +1,4 @@
import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoContinent = (typeof GeoContinents)[number];
@@ -1,5 +1,5 @@
const SERVICE_NAME = "incidentService";
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { getDateForRange } from "@/utils/dataUtils.js";
@@ -11,6 +11,7 @@ import type { User } from "@/domain/users/user.type.js";
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import type { ILogger } from "@/utils/logger.js";
import { DateRange } from "@/types/query.js";
export interface IIncidentService {
handleIncident(
@@ -23,7 +24,7 @@ export interface IIncidentService {
getIncidentsByTeam(
teamId: string,
sortOrder: string,
dateRange: string,
dateRange: DateRange,
page: number,
rowsPerPage: number,
status: boolean | undefined,
@@ -57,10 +58,6 @@ export class IncidentService implements IIncidentService {
this.notificationMessageBuilder = notificationMessageBuilder;
}
get serviceName() {
return IncidentService.SERVICE_NAME;
}
handleIncident = async (
monitor: Monitor,
code: number,
@@ -178,7 +175,7 @@ export class IncidentService implements IIncidentService {
getIncidentsByTeam = async (
teamId: string,
sortOrder: string,
dateRange: string,
dateRange: DateRange,
page: number,
rowsPerPage: number,
status: boolean | undefined,
+2 -2
View File
@@ -1,7 +1,7 @@
// export type IncidentResolutionType = "automatic" | "manual" | null;
export const IncidentResolutionTypes = ["automatic", "manual", null] as const;
export type IncidentResolutionType = (typeof IncidentResolutionTypes)[number];
export const IncidentResolutionTypes = ["automatic", "manual"] as const;
export type IncidentResolutionType = (typeof IncidentResolutionTypes)[number] | null;
export interface Incident {
id: string;
@@ -35,10 +35,6 @@ export class InviteService implements IInviteService {
this.emailService = emailService;
}
get serviceName() {
return InviteService.SERVICE_NAME;
}
getInviteToken = async ({ invite, teamId, userRoles }: { invite: Partial<Invite>; teamId: string; userRoles: UserRole[] }) => {
invite.teamId = teamId;
@@ -62,10 +62,6 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
this.scheduler = scheduler;
}
get serviceName() {
return MaintenanceWindowService.SERVICE_NAME;
}
private flipMonitorsLeavingMaintenance = async (monitorIds: string[], teamId: string, excludeWindowId: string, now: Date): Promise<void> => {
if (monitorIds.length === 0) return;
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.types.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.types.js";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js";
import type {
CheckAudits,
CheckCaptureInfo,
@@ -1,4 +1,4 @@
import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.types.js";
import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.type.js";
export interface TeamQueryConfig {
limit?: number;
@@ -26,9 +26,9 @@ export interface IMonitorsRepository {
findByIdLean(monitorId: string): Promise<Monitor | null>;
// collection fetch
findAll(): Promise<Monitor[] | null>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findAll(): Promise<Monitor[]>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[]>;
findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise<Monitor[]>;
findByIds(monitorIds: string[]): Promise<Monitor[]>;
findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise<Monitor[]>;
@@ -1,7 +1,7 @@
import { MonitorModel } from "@/domain/monitors/monitor.model.js";
import type { MonitorDocument, CheckSnapshotDocument } from "@/domain/monitors/monitor.model.js";
import type { CheckSnapshot } from "@/domain/checks/check.type.js";
import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.types.js";
import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js";
import mongoose, { type FilterQuery, type PipelineStage } from "mongoose";
import { MongoBulkWriteError } from "mongodb";
import { AppError } from "@/utils/AppError.js";
@@ -91,27 +91,26 @@ class MongoMonitorsRepository implements IMonitorsRepository {
{ $project: { stats: 0 } },
];
findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
private pageOptions = (config: TeamQueryConfig) => {
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
};
findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[]> => {
const query = this.queryBuilder(config, teamId);
const sort = { [field]: order === "asc" ? 1 : -1 } as const;
const skip = Math.max(page, 0) * rowsPerPage;
const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(rowsPerPage);
const { sort, skip, limit } = this.pageOptions(config);
const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(limit);
return this.mapDocuments(documents);
};
findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[]> => {
const { sort, skip, limit } = this.pageOptions(config);
const query = this.queryBuilder(config, teamId);
const sort = { [field]: order === "asc" ? 1 : -1 } as const;
const skip = Math.max(page, 0) * rowsPerPage;
const pipeline: PipelineStage[] = [
{ $match: query },
{ $sort: sort },
...(rowsPerPage ? [{ $skip: skip }, { $limit: rowsPerPage }] : []),
...(limit ? [{ $skip: skip }, { $limit: limit }] : []),
...this.uptimeStatsLookupStages,
];
+38 -84
View File
@@ -1,5 +1,4 @@
import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js";
import { type Monitor } from "@/domain/monitors/monitor.types.js";
import { type Monitor } from "@/domain/monitors/monitor.type.js";
import type {
MonitorType,
MonitorsWithChecksByTeamIdResult,
@@ -8,8 +7,8 @@ import type {
PageSpeedDetailsResult,
GamesMap,
GroupedGeoCheckResult,
} from "@/domain/monitors/monitor.types.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.types.js";
} from "@/domain/monitors/monitor.type.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js";
import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/domain/checks/check.type.js";
import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
@@ -23,29 +22,27 @@ import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/api/validation/monitorValidation.js";
import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
supportsUptimeDetails(result.monitorType);
export interface IMonitorService {
readonly serviceName: string;
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
// read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<PageSpeedDetailsResult>;
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<PageSpeedDetailsResult>;
getGeoChecksByMonitorId(args: {
teamId: string;
monitorId: string;
dateRange: string;
dateRange: DateRange;
continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
@@ -135,35 +132,6 @@ export class MonitorService implements IMonitorService {
this.incidentsRepository = incidentsRepository;
}
get serviceName(): string {
return MonitorService.SERVICE_NAME;
}
private getDateRange = (dateRange: DateRangeKey) => {
const startDates = {
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
all: new Date(0),
};
return {
start: startDates[dateRange],
end: new Date(),
};
};
private getDateFormat = (dateRange: DateRangeKey): string => {
const formatLookup = {
recent: "%Y-%m-%dT%H:%M:00Z",
day: "%Y-%m-%dT%H:00:00Z",
week: "%Y-%m-%dT00:00:00Z",
month: "%Y-%m-%dT00:00:00Z",
all: "%Y-%m-%dT00:00:00Z",
};
return formatLookup[dateRange];
};
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
@@ -206,15 +174,13 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
monitorId: string;
dateRange: string;
dateRange: DateRange;
}): Promise<UptimeDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
type: monitor.type,
});
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
@@ -226,9 +192,9 @@ export class MonitorService implements IMonitorService {
return {
monitorData: {
monitor,
groupedChecks: NormalizeDataUptimeDetails(checksData.groupedChecks, 10, 100),
groupedUpChecks: NormalizeDataUptimeDetails(checksData.groupedUpChecks, 10, 100),
groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100),
groupedChecks: checksData.groupedChecks,
groupedUpChecks: checksData.groupedUpChecks,
groupedDownChecks: checksData.groupedDownChecks,
groupedAvgResponseTime: checksData.avgResponseTime,
groupedUptimePercentage: checksData.uptimePercentage,
},
@@ -243,7 +209,7 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
monitorId: string;
dateRange: string;
dateRange: DateRange;
}): Promise<HardwareDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
@@ -253,9 +219,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
type: monitor.type,
});
@@ -285,7 +249,7 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
monitorId: string;
dateRange: string;
dateRange: DateRange;
}): Promise<PageSpeedDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
@@ -295,9 +259,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
type: monitor.type,
});
@@ -324,7 +286,7 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
monitorId: string;
dateRange: string;
dateRange: DateRange;
continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
@@ -336,15 +298,7 @@ export class MonitorService implements IMonitorService {
return { groupedGeoChecks: [] };
}
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
monitor.id,
start,
end,
this.getDateFormat(rangeKey),
continents
);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(monitor.id, dateRange, continents);
return { groupedGeoChecks };
};
@@ -363,7 +317,7 @@ export class MonitorService implements IMonitorService {
type?: MonitorType | MonitorType[];
tags?: string | string[];
filter?: string;
}): Promise<Monitor[] | null> => {
}): Promise<Monitor[]> => {
return await this.monitorsRepository.findByTeamId(teamId, { type, tags, filter });
};
@@ -401,16 +355,15 @@ export class MonitorService implements IMonitorService {
order,
});
const monitorsList = (monitors ?? []) as Monitor[];
const snapshotTypes: MonitorType[] = ["hardware"];
const requestedTypes = Array.isArray(type) ? type : type ? [type] : [];
const snapshotOnlyRequest =
requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType));
const monitorsWithChecks = monitorsList.map((monitor: Monitor) => {
const monitorsWithChecks = monitors.map((monitor: Monitor) => {
const rawChecks = monitor.recentChecks ?? [];
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;
return monitor;
});
@@ -496,15 +449,7 @@ export class MonitorService implements IMonitorService {
return { monitors, failedCount };
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
private deleteMonitorChildren = async (monitor: Monitor, teamId: string) => {
await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`,
@@ -536,6 +481,19 @@ export class MonitorService implements IMonitorService {
});
});
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
await this.deleteMonitorChildren(monitor, teamId);
await this.scheduler.deleteJob(monitor);
return monitor;
};
@@ -545,12 +503,8 @@ export class MonitorService implements IMonitorService {
await Promise.all(
monitors.map(async (monitor) => {
try {
await this.deleteMonitorChildren(monitor, teamId);
await this.scheduler.deleteJob(monitor);
await this.checksRepository.deleteByMonitorId(monitor.id);
await this.geoChecksRepository.deleteByMonitorId(monitor.id);
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id);
await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId);
} catch (error: unknown) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`,
@@ -567,7 +521,7 @@ export class MonitorService implements IMonitorService {
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<Monitor[]> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (!monitors || monitors.length === 0) {
if (monitors.length === 0) {
throw new AppError({ message: "No monitors found to export.", service: SERVICE_NAME, method: "exportMonitorsToJSON", status: 400 });
}
@@ -3,6 +3,7 @@ export type { CheckSnapshot } from "@/domain/checks/check.type.js";
import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import http from "node:http";
import { HardwareStats } from "@/domain/checks/check.type.js";
export const HttpStatusCodes = [
...Object.keys(http.STATUS_CODES).map(Number),
@@ -149,48 +150,6 @@ export interface UptimeDetailsResult {
monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null;
}
export interface HardwareDiskStats {
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}
export interface HardwareNetStats {
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}
export interface HardwareCheckStats {
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: HardwareDiskStats[];
net: HardwareNetStats[];
}
export interface HardwareStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: HardwareCheckStats[];
}
export interface HardwareDetailsResult {
monitor: Monitor;
stats: HardwareStats;
@@ -1,4 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type {
@@ -1,4 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { Notification } from "@/domain/notifications/notification.type.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
@@ -9,6 +9,9 @@ import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import type { NotificationChannel } from "@/domain/notifications/notification.type.js";
export type NotificationProviderRegistry = Record<NotificationChannel, INotificationProvider>;
export interface INotificationsService {
createNotification: (notificationData: Partial<Notification>, userId: string, teamId: string) => Promise<Notification>;
@@ -29,52 +32,29 @@ export class NotificationsService implements INotificationsService {
private notificationsRepository: INotificationsRepository;
private monitorsRepository: IMonitorsRepository;
private webhookProvider: INotificationProvider;
private emailProvider: INotificationProvider;
private slackProvider: INotificationProvider;
private discordProvider: INotificationProvider;
private pagerDutyProvider: INotificationProvider;
private matrixProvider: INotificationProvider;
private teamsProvider: INotificationProvider;
private telegramProvider: INotificationProvider;
private pushoverProvider: INotificationProvider;
private twilioProvider: INotificationProvider;
private ntfyProvider: INotificationProvider;
private logger: ILogger;
private providers: NotificationProviderRegistry;
private settingsService: ISettingsService;
private logger: ILogger;
private notificationMessageBuilder: INotificationMessageBuilder;
constructor(
notificationsRepository: INotificationsRepository,
monitorsRepository: IMonitorsRepository,
webhookProvider: INotificationProvider,
emailProvider: INotificationProvider,
slackProvider: INotificationProvider,
discordProvider: INotificationProvider,
pagerDutyProvider: INotificationProvider,
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
telegramProvider: INotificationProvider,
pushoverProvider: INotificationProvider,
twilioProvider: INotificationProvider,
ntfyProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
) {
constructor({
notificationsRepository,
monitorsRepository,
providers,
settingsService,
logger,
notificationMessageBuilder,
}: {
notificationsRepository: INotificationsRepository;
monitorsRepository: IMonitorsRepository;
providers: NotificationProviderRegistry;
settingsService: ISettingsService;
logger: ILogger;
notificationMessageBuilder: INotificationMessageBuilder;
}) {
this.notificationsRepository = notificationsRepository;
this.monitorsRepository = monitorsRepository;
this.webhookProvider = webhookProvider;
this.emailProvider = emailProvider;
this.slackProvider = slackProvider;
this.discordProvider = discordProvider;
this.pagerDutyProvider = pagerDutyProvider;
this.matrixProvider = matrixProvider;
this.teamsProvider = teamsProvider;
this.telegramProvider = telegramProvider;
this.pushoverProvider = pushoverProvider;
this.twilioProvider = twilioProvider;
this.ntfyProvider = ntfyProvider;
this.providers = providers;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -97,37 +77,16 @@ export class NotificationsService implements INotificationsService {
}
// Route to provider based on notification type
switch (notification.type) {
case "webhook":
return await this.webhookProvider.sendMessage!(notification, notificationMessage);
case "slack":
return await this.slackProvider.sendMessage!(notification, notificationMessage);
case "matrix":
return await this.matrixProvider.sendMessage!(notification, notificationMessage);
case "pager_duty":
return await this.pagerDutyProvider.sendMessage!(notification, notificationMessage);
case "discord":
return await this.discordProvider.sendMessage!(notification, notificationMessage);
case "email":
return await this.emailProvider.sendMessage!(notification, notificationMessage);
case "teams":
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
case "telegram":
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
case "pushover":
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
case "twilio":
return await this.twilioProvider.sendMessage!(notification, notificationMessage);
case "ntfy":
return await this.ntfyProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
service: SERVICE_NAME,
method: "send",
});
return false;
const provider = this.providers[notification.type];
if (!provider) {
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
service: SERVICE_NAME,
method: "send",
});
return false;
}
return await provider.sendMessage(notification, notificationMessage);
};
private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => {
@@ -165,32 +124,26 @@ export class NotificationsService implements INotificationsService {
};
sendTestNotification = async (notification: Partial<Notification>) => {
switch (notification.type) {
case "email":
return await this.emailProvider.sendTestAlert(notification);
case "slack":
return await this.slackProvider.sendTestAlert(notification);
case "discord":
return await this.discordProvider.sendTestAlert(notification);
case "pager_duty":
return await this.pagerDutyProvider.sendTestAlert(notification);
case "matrix":
return await this.matrixProvider.sendTestAlert(notification);
case "webhook":
return await this.webhookProvider.sendTestAlert(notification);
case "teams":
return await this.teamsProvider.sendTestAlert(notification);
case "telegram":
return await this.telegramProvider.sendTestAlert(notification);
case "pushover":
return await this.pushoverProvider.sendTestAlert(notification);
case "twilio":
return await this.twilioProvider.sendTestAlert(notification);
case "ntfy":
return await this.ntfyProvider.sendTestAlert(notification);
default:
return false;
const type = notification.type;
if (!type) {
this.logger.warn({
message: "Notification type not provided",
service: SERVICE_NAME,
method: "sendTestNotification",
});
return false;
}
const provider = this.providers[type];
if (!provider) {
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
service: SERVICE_NAME,
method: "sendTestNotification",
});
return false;
}
return await provider.sendTestAlert(notification);
};
testAllNotifications = async (notificationIds: string[]) => {
@@ -4,7 +4,8 @@ import { QueueWorkerDocument } from "@/domain/queue-workers/queue-worker.type.js
// Single source of truth for worker liveness: the TTL that GCs stale records and
// the window readers use to count a worker as alive. Heartbeat cadence must stay well below it.
export const WORKER_TTL_SECONDS = 180;
const WORKER_TTL_SECONDS = 180;
export const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000;
const QueueWorkerSchema = new Schema<QueueWorkerDocument>(
{
@@ -9,8 +9,7 @@ import {
} from "@/domain/status-pages/status-page.type.js";
import { AppError } from "@/utils/AppError.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { NormalizeData } from "@/utils/dataUtils.js";
import { Monitor } from "@/domain/monitors/monitor.type.js";
export interface IStatusPageService {
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,
uptimePercentage: monitor.uptimePercentage,
recentChecks: monitor.recentChecks,
checks: NormalizeData(monitor.recentChecks, 10, 100),
};
if (showURL) {
@@ -1,5 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { NormalizedCheck, CheckSnapshot } from "@/domain/checks/check.type.js";
import type { Monitor } from "@/domain/monitors/monitor.type.js";
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
@@ -48,7 +47,7 @@ export interface StatusPage {
}
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 {
statusPage: StatusPage;
@@ -2,7 +2,6 @@ import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import { UserModel, type UserDocument } from "@/domain/users/user.model.js";
import type { User, UserProfileImage } from "@/domain/users/user.type.js";
import { GenerateAvatarImage } from "@/utils/imageProcessing.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
const SERVICE_NAME = "MongoUsersRepository";
@@ -72,7 +71,7 @@ class MongoUsersRepository implements IUsersRepository {
findById = async (id: string) => {
const user = await UserModel.findById(id).select("-password").select("-profileImage");
if (!user) {
throw new Error("User not found");
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(user);
@@ -87,9 +86,8 @@ class MongoUsersRepository implements IUsersRepository {
const candidateUser = { ...patch };
let unsetFields: Record<string, 1> | undefined;
if (ParseBoolean(candidateUser.deleteProfileImage) === true) {
if (candidateUser.deleteProfileImage === true) {
unsetFields = { profileImage: 1, avatarImage: 1 };
delete candidateUser.deleteProfileImage;
} else if (file) {
// 1. Save the full size image
candidateUser.profileImage = {
+2 -6
View File
@@ -24,7 +24,7 @@ export interface IUserService {
createUser(userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null): Promise<User>;
loginUser(email: string, password: string): Promise<{ user: User; token: string }>;
editUser(
updates: Partial<User & { newPassword?: string }>,
updates: Partial<User & { newPassword?: string; deleteProfileImage?: boolean }>,
file: Express.Multer.File | null,
currentUserId: string,
currentUserEmail: string
@@ -103,10 +103,6 @@ export class UserService implements IUserService {
this.teamsRepository = teamsRepository;
}
get serviceName() {
return UserService.SERVICE_NAME;
}
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
};
@@ -242,7 +238,7 @@ export class UserService implements IUserService {
};
editUser = async (
updates: Partial<User & { newPassword?: string }>,
updates: Partial<User & { newPassword?: string; deleteProfileImage?: boolean }>,
file: Express.Multer.File | null,
currentUserId: string,
currentUserEmail: string
+1 -1
View File
@@ -27,7 +27,7 @@ const startApp = async () => {
const env = validateEnv(logger);
logger.setLogLevel(env.LOG_LEVEL);
const settingsService = new SettingsService(env);
const envSettings = settingsService.loadSettings();
const envSettings = settingsService.getSettings();
const { queueMode, queuePrimaryProcesses } = envSettings;
logger.info({
-4
View File
@@ -51,10 +51,6 @@ export class BufferService implements IBufferService {
});
}
get serviceName() {
return BufferService.SERVICE_NAME;
}
addToBuffer(check: Check) {
try {
this.buffer.push(check);
-5
View File
@@ -56,10 +56,6 @@ export class EmailService implements IEmailService {
this.init();
}
get serviceName() {
return EmailService.SERVICE_NAME;
}
init = () => {
const loadTemplate = (templateName: string) => {
try {
@@ -79,7 +75,6 @@ export class EmailService implements IEmailService {
this.templateLookup = {
welcomeEmailTemplate: loadTemplate("welcomeEmail"),
employeeActivationTemplate: loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: loadTemplate("passwordReset"),
testEmailTemplate: loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
+2 -7
View File
@@ -1,6 +1,6 @@
import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/domain/geo-checks/geo-check.type.js";
import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor.types.js";
import { MonitorType } from "@/domain/monitors/monitor.types.js";
import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor.type.js";
import { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { ILogger } from "@/utils/logger.js";
import got from "got";
import { isStatusUp } from "@/service/network/utils.js";
@@ -61,7 +61,6 @@ interface GlobalPingProbeResult {
}
export interface IGlobalPingService {
readonly serviceName: string;
createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null>;
pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: HttpStatusCode[]): Promise<GeoCheckResult[]>;
}
@@ -75,10 +74,6 @@ export class GlobalPingService implements IGlobalPingService {
this.logger = logger;
}
get serviceName() {
return GlobalPingService.SERVICE_NAME;
}
async createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null> {
try {
if (!supportsGeoCheck(monitorType)) {
@@ -1,4 +1,4 @@
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { Monitor } from "@/domain/monitors/monitor.type.js";
import jmespath from "jmespath";
import RE2 from "re2";
type JmesPath = typeof jmespath;
+3 -2
View File
@@ -1,9 +1,10 @@
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import type { Resolver } from "dns/promises";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
const SERVICE_NAME = "DNSProvider";
+3 -3
View File
@@ -1,11 +1,11 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode";
import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
type DockerodeType = typeof Dockerode;
const SERVICE_NAME = "DockerProvider";
+2 -2
View File
@@ -1,9 +1,9 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { GameDig } from "gamedig";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { ILogger } from "@/utils/logger.js";
type GameDigType = typeof GameDig;
+1 -1
View File
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js";
import * as grpc from "@grpc/grpc-js";
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { AppError } from "@/utils/AppError.js";
+5 -4
View File
@@ -5,8 +5,9 @@ import { HttpStatusPayload } from "@/types/network.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { Agent as HttpsAgent } from "https";
import { Agent as HttpAgent } from "http";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { NETWORK_ERROR, isStatusUp } from "@/service/network/utils.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { isStatusUp } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import CacheableLookup from "cacheable-lookup";
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
@@ -116,7 +117,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
if (error instanceof HTTPError || error instanceof RequestError) {
const statusCode = error.response?.statusCode;
const statusUp = isStatusUp(statusCode, monitor.customUpCodes);
const responseTime = error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0;
const responseTime = error.timings?.phases?.total ?? 0;
if (!statusUp) {
return {
@@ -183,7 +184,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
statusCode: response.statusCode,
statusUp,
message: response.statusMessage ?? "OK",
responseTime: response.timings.phases.firstByte ?? 0,
responseTime: response.timings.phases.total ?? 0,
timings: response.timings,
});
} catch (error: unknown) {
@@ -1,4 +1,4 @@
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { MonitorStatusResponse } from "@/types/network.js";
export interface IStatusProvider<T> {
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.type.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js";
+1 -1
View File
@@ -1,6 +1,6 @@
import { PingStatusPayload } from "@/types/network.js";
import { IStatusProvider } from "./IStatusProvider.js";
import { MonitorType, Monitor } from "@/domain/monitors/monitor.types.js";
import { MonitorType, Monitor } from "@/domain/monitors/monitor.type.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import ping from "ping";
+3 -2
View File
@@ -1,8 +1,9 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import * as net from "net";
type NetType = typeof net;
@@ -1,8 +1,9 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import type WebSocket from "ws";
type WebSocketConstructor = typeof WebSocket;
+1 -4
View File
@@ -1,4 +1,4 @@
import type { HttpStatusCode } from "@/domain/monitors/monitor.types.js";
import type { HttpStatusCode } from "@/domain/monitors/monitor.type.js";
export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> => {
const start = process.hrtime.bigint();
@@ -12,9 +12,6 @@ export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ res
}
};
export const NETWORK_ERROR = 5000;
export const PING_ERROR = 5001;
export const isStatusUp = (statusCode: number | undefined, customUpCodes: HttpStatusCode[] = []): boolean => {
if (statusCode === undefined) return false;
return (statusCode >= 200 && statusCode < 300) || customUpCodes.includes(statusCode);
+2 -7
View File
@@ -1,14 +1,13 @@
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import type { AxiosStatic } from "axios";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { ILogger } from "@/utils/logger.js";
import { IStatusProvider } from "./network/IStatusProvider.js";
const SERVICE_NAME = "NetworkService";
export interface INetworkService {
readonly serviceName: string;
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestWebhook(
type: string,
@@ -39,10 +38,6 @@ export class NetworkService implements INetworkService {
this.logger = logger;
}
get serviceName(): string {
return NetworkService.SERVICE_NAME;
}
// Main entry point
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
const provider = this.providers.find((p) => p.supports(monitor.type));
+7 -8
View File
@@ -1,7 +1,7 @@
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.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 { Monitor, MonitorStatus } from "@/domain/monitors/monitor.types.js";
import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js";
import type {
DockerStatusPayload,
GameStatusPayload,
@@ -17,7 +17,7 @@ import type {
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import type { HardwareStatusMetrics } from "@/types/network.js";
import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.types.js";
import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.type.js";
const SERVICE_NAME = "StatusService";
const HARDWARE_ALERT_COUNTER_START = 5;
@@ -57,10 +57,6 @@ export class StatusService implements IStatusService {
this.monitorStatsRepository = monitorStatsRepository;
}
get serviceName() {
return StatusService.SERVICE_NAME;
}
async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) {
try {
await this.monitorStatsRepository.updateByMonitorId(monitor.id, {
@@ -222,10 +218,13 @@ export class StatusService implements IStatusService {
const patch: Partial<Monitor> = {};
// 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 statusChanged = false;
if (monitor.status === "initializing" || monitor.status === "maintenance") {
newStatus = status === true ? "up" : "down";
if (monitor.status === "initializing" || monitor.status === "maintenance" || isUnknownStatus) {
newStatus = status ? "up" : "down";
patch.status = newStatus;
statusChanged = newStatus === "down";
}
-58
View File
@@ -1,58 +0,0 @@
<!-- name -->
<mjml>
<mj-head>
<mj-font
name="Roboto"
href="https://fonts.googleapis.com/css?family=Roboto:300,500"
></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text
font-weight="300"
font-size="16px"
color="#616161"
line-height="24px"
></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text
align="left"
font-size="10px"
>
Message from Checkmate Service
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{ name }}!</p>
<p>
We hope you’re finding Checkmate helpful in monitoring your infrastructure. Your support means a lot to us, and we
<b>truly appreciate</b> having you as part of our community.
</p>
<p>
If you’re happy with Checkmate, we’d love to hear about your experience! Leaving a review on G2 helps others discover Checkmate and
supports our ongoing improvements.
</p>
G2 Link: TBD
<p>Thank you for taking the time to share your thoughts - we greatly appreciate it!</p>
Checkmate Team
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-divider
border-width="1px"
border-color="#E0E0E0"
></mj-divider>
<mj-text font-size="12px">
<p>This email was sent by Checkmate.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more