Compare commits

..
Author SHA1 Message Date
gorkem-bwl 0b09895098 docs: align dev setup docs with current server config
## Changes
- Replace stale QUEUE_TYPE env var (superSimpleQueue/lessSimpleQueue) with QUEUE_MODE (primary/worker) to match server/src/config/envValidation.ts
- Mark ORIGIN, TOKEN_TTL and LOG_LEVEL as optional with their defaults; note that only CLIENT_HOST, JWT_SECRET and DB_CONNECTION_STRING are strictly required
- Note that CLIENT_HOST must be a full URL (enforced by env validation)
- Remove Redis port 6379 from the port-conflict troubleshooting list; the server has no Redis dependency
- Point CLAUDE.md at CONTRIBUTING.md as the setup source of truth

## Why
A contributor followed the setup docs and hit references to files and env vars that no longer exist after the repo reorg. This brings the contributor docs in line with the actual dev workflow.
2026-07-21 19:44:22 +03:00
157 changed files with 1542 additions and 1893 deletions

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ TOKEN_TTL="99d"
ORIGIN="localhost" ORIGIN="localhost"
LOG_LEVEL="debug" LOG_LEVEL="debug"
``` ```
Only `CLIENT_HOST` (must be a full URL), `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required; the rest have defaults (see `server/src/config/envValidation.ts`). Setup source of truth: CONTRIBUTING.md.
### Client `.env` ### Client `.env`
```env ```env
+7 -5
View File
@@ -124,10 +124,12 @@ LOG_LEVEL="debug"
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173) - `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure) - `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
- `DB_CONNECTION_STRING`: MongoDB connection URL - `DB_CONNECTION_STRING`: MongoDB connection URL
- `ORIGIN`: Origin for CORS purposes - `ORIGIN`: Origin for CORS purposes (optional)
- `TOKEN_TTL`: Token time to live (in vercel/ms format) - `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`)
- `LOG_LEVEL`: Debug level (debug, info, warn, error) - `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`)
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`) - `QUEUE_MODE`: Queue mode, `primary` or `worker` (optional, default: `primary`)
Only `CLIENT_HOST`, `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required. Note that `CLIENT_HOST` must be a full URL.
Start the backend server: Start the backend server:
@@ -203,7 +205,7 @@ docker rm uptime_database_mongo
**Port already in use:** **Port already in use:**
- Check if another service is using ports 5173, 52345, 27017, or 6379 - Check if another service is using ports 5173, 52345, or 27017
- Stop the conflicting service or change the port in `.env` files - Stop the conflicting service or change the port in `.env` files
**MongoDB connection issues:** **MongoDB connection issues:**
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "client", "name": "client",
"version": "3.10.0", "version": "3.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "client", "name": "client",
"version": "3.10.0", "version": "3.9.2",
"dependencies": { "dependencies": {
"@csstools/css-tokenizer": "^3.0.4", "@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.10.0", "version": "3.9.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -7,7 +7,6 @@ import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import prettyMilliseconds from "pretty-ms";
type HeatmapCheck = type HeatmapCheck =
| CheckSnapshot | CheckSnapshot
@@ -59,9 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
</Typography> </Typography>
<Typography> <Typography>
{t("common.labels.responseTime")}:{" "} {t("common.labels.responseTime")}:{" "}
{check?.responseTime {check?.originalResponseTime?.toFixed() ?? "N/A"} ms
? prettyMilliseconds(check.responseTime, { compact: true })
: "N/A"}
</Typography> </Typography>
<Typography textTransform={"capitalize"}> <Typography textTransform={"capitalize"}>
Status:{" "} Status:{" "}
@@ -8,7 +8,6 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils";
interface HistogramResponseTimeProps { interface HistogramResponseTimeProps {
checks: CheckSnapshot[]; checks: CheckSnapshot[];
@@ -30,12 +29,12 @@ const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats
return { max: "-", avg: "-" }; return { max: "-", avg: "-" };
} }
const validChecks = checks.filter((check) => check.responseTime != null); const validChecks = checks.filter((check) => check.originalResponseTime != null);
if (validChecks.length === 0) { if (validChecks.length === 0) {
return { max: "-", avg: "-" }; return { max: "-", avg: "-" };
} }
const responseTimes = validChecks.map((check) => check.responseTime); const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = Math.round(Math.max(...responseTimes)); const max = Math.round(Math.max(...responseTimes));
const avg = Math.round( const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
@@ -55,7 +54,7 @@ export const HistogramResponseTime = ({
const { t } = useTranslation(); const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]); const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null; if (!Array.isArray(checks) || checks.length === 0) return null;
const data = const data =
@@ -112,7 +111,7 @@ export const HistogramResponseTime = ({
> >
{data.map((check, index) => { {data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder"; const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${barHeights[index] ?? 0}%`; const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor = const barColor =
check.status === true check.status === true
? theme.palette.success.light ? theme.palette.success.light
@@ -13,7 +13,6 @@ type BaseChartProps = React.PropsWithChildren<{
width?: number | string; width?: number | string;
maxWidth?: number | string; maxWidth?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>; padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>; }>;
export const BaseChart = ({ export const BaseChart = ({
@@ -22,22 +21,16 @@ export const BaseChart = ({
width = "100%", width = "100%",
maxWidth = "100%", maxWidth = "100%",
padding, padding,
onClick,
}: BaseChartProps) => { }: BaseChartProps) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<BaseBox <BaseBox
onClick={onClick}
sx={{ sx={{
padding: padding ?? theme.spacing(LAYOUT.MD), padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex", display: "flex",
flex: 1, flex: 1,
width: width, width: width,
maxWidth: maxWidth, maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}} }}
> >
<Stack <Stack
@@ -47,7 +40,6 @@ export const BaseChart = ({
<Typography <Typography
variant="eyebrow" variant="eyebrow"
color="text.secondary" color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
> >
{title} {title}
</Typography> </Typography>
+4 -14
View File
@@ -1,28 +1,18 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
export type DotSize = (typeof DotSizes)[number];
const DOT_SIZE_PX: Record<DotSize, string> = {
xs: "2px",
sm: "4px",
md: "8px",
lg: "12px",
};
interface DotProps { interface DotProps {
color?: string; color?: string;
size?: DotSize; size?: string;
style?: CSSProperties; style?: CSSProperties;
} }
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => { export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
return ( return (
<span <span
style={{ style={{
content: '""', content: '""',
width: DOT_SIZE_PX[size], width: size,
height: DOT_SIZE_PX[size], height: size,
borderRadius: "50%", borderRadius: "50%",
backgroundColor: color, backgroundColor: color,
opacity: 0.8, opacity: 0.8,
@@ -1,34 +0,0 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
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,40 +1,53 @@
import Stack from "@mui/material/Stack";
import { BaseChart, BaseBox } from "@/Components/design-elements"; import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Clock } from "lucide-react"; import { Clock } from "lucide-react";
import { Dot } from "@/Components/design-elements";
import { import {
AreaChart, AreaChart,
Area, Area,
XAxis, XAxis,
YAxis,
Tooltip, Tooltip,
CartesianGrid, CartesianGrid,
ResponsiveContainer, ResponsiveContainer,
Text,
} from "recharts"; } from "recharts";
import type { TooltipProps } from "recharts"; import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import prettyMilliseconds from "pretty-ms";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next"; import {
import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles";
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check"; import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
import { useMemo, useState } from "react"; type XTickProps = {
import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; x: number;
import { y: number;
PHASE_COLOR_KEYS, payload: { value: string };
PHASE_KEYS, range: string;
PHASE_LABEL_KEYS, };
} from "@/Components/monitors/charts/ChartUtils";
type DetailsCheck = GroupedCheck & export const XTick = ({ x, y, payload, range }: XTickProps) => {
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>; 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 ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & { type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string; range: string;
@@ -50,72 +63,17 @@ const ResponseTimeToolTip = ({
theme, theme,
uiTimezone, uiTimezone,
}: ResponseTimeToolTipProps) => { }: ResponseTimeToolTipProps) => {
const { t } = useTranslation();
if (!label) return null; if (!label) return null;
if (!payload) return null; if (!payload) return null;
if (!active) return null; if (!active) return null;
const format = tooltipDateFormatLookup(range); const format = tooltipDateFormatLookup(range);
const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0); console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
return ( return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}> <BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography> <Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography> <Typography>Response time: {responseTime} ms</Typography>
{t("common.charts.histogram.responseTime", {
value: prettyMilliseconds(responseTime, {
formatSubMilliseconds: true,
compact: true,
}),
})}
</Typography>
</BaseBox>
);
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: Theme;
uiTimezone: string;
};
const TimingPhasesToolTip = ({
active,
payload,
range,
theme,
uiTimezone,
}: TimingPhaseToolTipProps) => {
const { t } = useTranslation();
if (!active || !payload?.length) return null;
const bucket = payload[0].payload as GroupedUptimeCheck;
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
const format = tooltipDateFormatLookup(range);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.total", { value: prettyMilliseconds(total) })}
</Typography>
{PHASE_KEYS.map((key) => (
<Stack
alignItems={"center"}
direction="row"
key={key}
gap={SPACING.LG}
>
<Dot
size="lg"
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
/>
<Typography>
{t(PHASE_LABEL_KEYS[key])}:{" "}
{prettyMilliseconds(bucket[key], {
formatSubMilliseconds: true,
compact: true,
})}
</Typography>
</Stack>
))}
</BaseBox> </BaseBox>
); );
}; };
@@ -124,28 +82,11 @@ export const HistogramDetails = ({
checks, checks,
range, range,
}: { }: {
checks: DetailsCheck[]; checks: GroupedCheck[];
range: string; range: string;
}) => { }) => {
const [showTimingPhases, setShowTimingPhases] = useState(false);
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const yMax = useMemo(() => {
const totals = checks.map((check) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return ( return (
<BaseChart <BaseChart
icon={ icon={
@@ -154,15 +95,7 @@ export const HistogramDetails = ({
strokeWidth={1.5} strokeWidth={1.5}
/> />
} }
title={ title="Response times"
showTimingPhases
? t("common.charts.labels.timingPhases")
: t("common.charts.labels.responseTime")
}
onClick={() => {
if (!hasTimingPhases) return;
setShowTimingPhases((prev) => !prev);
}}
> >
<ResponsiveContainer <ResponsiveContainer
width="100%" width="100%"
@@ -195,34 +128,7 @@ export const HistogramDetails = ({
stopOpacity={0} stopOpacity={0}
/> />
</linearGradient> </linearGradient>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs> </defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis <XAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
@@ -235,18 +141,6 @@ export const HistogramDetails = ({
)} )}
/> />
{showTimingPhases ? (
<Tooltip
content={(props) => (
<TimingPhasesToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
) : (
<Tooltip <Tooltip
content={(props) => ( content={(props) => (
<ResponseTimeToolTip <ResponseTimeToolTip
@@ -257,77 +151,14 @@ export const HistogramDetails = ({
/> />
)} )}
/> />
)}
{!showTimingPhases && (
<Area <Area
type="monotone" type="monotone"
dataKey="avgResponseTime" dataKey="avgResponseTime"
stroke={theme.palette.primary.main} stroke={theme.palette.primary.main}
fill="url(#colorUv)" fill="url(#colorUv)"
/> />
)}
{hasTimingPhases &&
showTimingPhases &&
PHASE_KEYS.map((key) => {
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
return (
<Area
key={key}
type="monotone"
stackId={1}
dataKey={key}
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
fill={`url(#phaseGradient-${key})`}
fillOpacity={dimmed ? 0.2 : undefined}
strokeOpacity={dimmed ? 0.2 : undefined}
/>
);
})}
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
<Stack
justifyContent={"center"}
gap={LAYOUT.XS}
direction={"row"}
flexWrap={"wrap"}
>
{showTimingPhases ? (
PHASE_KEYS.map((key) => {
return (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
key={key}
onMouseEnter={() => setHoveredPhase(key)}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
size="lg"
/>
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
</Stack>
);
})
) : (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.primary.main}
size="lg"
/>
<Typography lineHeight={1}>
{t("common.charts.labels.totalResponseTime")}
</Typography>
</Stack>
)}
</Stack>
</BaseChart> </BaseChart>
); );
}; };
@@ -8,7 +8,7 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { Fragment, useId } from "react"; import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors/charts/XTick"; import { XTick } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor"; import type { HardwareCheckStats } from "@/Types/Monitor";
@@ -1,6 +1,6 @@
import { BaseChart } from "@/Components/design-elements"; import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react"; import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors/charts/XTick"; import { XTick } from "@/Components/monitors";
import { import {
XAxis, XAxis,
AreaChart, AreaChart,
@@ -1,30 +0,0 @@
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,11 +25,10 @@ export const InfraDetailsGauges = ({
<Stack <Stack
direction={isSmall ? "column" : "row"} direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)} spacing={theme.spacing(8)}
alignItems={"stretch"} alignItems={"center"}
> >
<DetailGauge <DetailGauge
title={t("pages.infrastructure.gauges.memory.title")} title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100} progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")} upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)} upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
@@ -38,7 +37,6 @@ export const InfraDetailsGauges = ({
/> />
<DetailGauge <DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")} title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100} progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")} upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)} upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
@@ -51,7 +49,6 @@ export const InfraDetailsGauges = ({
key={disk?.device || 0 + idx} key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`} // title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })} title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={260}
progress={(disk.usage_percent || 0) * 100} progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")} upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)} upperValue={prettyBytes(disk?.used_bytes || 0)}
+2 -1
View File
@@ -13,12 +13,13 @@ const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = []; const checks: CheckSnapshot[] = [];
for (let i = 0; i < MAX_RECENT_CHECKS; i++) { for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1; const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 800) + 20; const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({ checks.push({
id: `dummy-${i}`, id: `dummy-${i}`,
status: isUp, status: isUp,
statusCode: isUp ? 200 : 500, statusCode: isUp ? 200 : 500,
responseTime: responseTime, responseTime: responseTime,
originalResponseTime: responseTime,
message: "", message: "",
createdAt: new Date(Date.now() - i * 60000).toISOString(), createdAt: new Date(Date.now() - i * 60000).toISOString(),
}); });
@@ -29,7 +29,7 @@ import {
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { formatPercentage } from "@/Utils/FormatUtils"; import { formatPercentage } from "@/Utils/FormatUtils";
type StatusPageMonitor = Monitor; type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
export interface BaseStyles { export interface BaseStyles {
page: SxProps<Theme>; page: SxProps<Theme>;
@@ -1,30 +0,0 @@
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,10 +1,13 @@
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CheckSnapshot } from "@/Types/Check"; import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = MAX_RECENT_CHECKS; const CELLS = MAX_RECENT_CHECKS;
@@ -26,6 +29,7 @@ const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const source = checks.slice(-CELLS); const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [ const padded: (CheckSnapshot | null)[] = [
@@ -49,7 +53,24 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
); );
} }
const kind = classify(check); const kind = classify(check);
const tooltipContent = <ThemedChartTooltip check={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>
);
return ( return (
<Tooltip <Tooltip
key={check.id ?? i} key={check.id ?? i}
@@ -2,12 +2,13 @@ import { useMemo } from "react";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CheckSnapshot } from "@/Types/Check"; import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils"; import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
const CELLS = MAX_RECENT_CHECKS; const CELLS = MAX_RECENT_CHECKS;
const MIN_HEIGHT_PCT = 6; const MIN_HEIGHT_PCT = 6;
@@ -32,20 +33,20 @@ export const ThemedHistogram = ({
statsGap = 1, statsGap = 1,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const { padded, heights, avg, peak } = useMemo(() => { const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(-CELLS); const source = checks.slice(-CELLS);
const out: (CheckSnapshot | null)[] = [ const out: (CheckSnapshot | null)[] = [
...source, ...source,
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
]; ];
const heights = computeBarHeights(source);
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1; const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
const avgRt = valid.length const avgRt = valid.length
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length) ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
: 0; : 0;
return { padded: out, heights, avg: avgRt, peak: maxRt }; return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
}, [checks]); }, [checks]);
return ( return (
@@ -60,8 +61,32 @@ export const ThemedHistogram = ({
/> />
); );
} }
const height = heights[i] ?? MIN_HEIGHT_PCT; const height = Math.max(
const tooltipContent = <ThemedChartTooltip check={check} />; 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>
);
return ( return (
<Tooltip <Tooltip
key={check.id ?? i} key={check.id ?? i}
@@ -18,7 +18,7 @@ export interface InfraSx {
} }
interface Props { interface Props {
monitor: Monitor; monitor: Monitor & { checks?: Monitor["recentChecks"] };
sxApi: InfraSx; sxApi: InfraSx;
} }
@@ -36,7 +36,7 @@ const heatLevel = (value: number): GaugeFillLevel =>
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => { export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const latest = monitor.recentChecks?.[0]; const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
const renderEmpty = () => ( const renderEmpty = () => (
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box> <Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
+3 -11
View File
@@ -57,6 +57,7 @@ const UptimeDetailsPage = () => {
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("dateRange", dateRange); params.append("dateRange", dateRange);
params.append("normalize", "true");
return `/monitors/uptime/details/${monitorId}?${params.toString()}`; return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
}, [monitorId, dateRange]); }, [monitorId, dateRange]);
@@ -71,7 +72,6 @@ const UptimeDetailsPage = () => {
); );
const monitorData = monitorDetailsData?.monitorData; const monitorData = monitorDetailsData?.monitorData;
const monitor = monitorData?.monitor; const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null; const monitorStats = monitorDetailsData?.monitorStats ?? null;
@@ -165,18 +165,10 @@ const UptimeDetailsPage = () => {
{ keepPreviousData: true, revalidateOnFocus: false } { keepPreviousData: true, revalidateOnFocus: false }
); );
if (!monitorData) {
return null;
}
if (!monitor) {
return null;
}
const geoChecksForTable = geoChecksTableData?.geoChecks ?? []; const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0; const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
const geoLocations = monitor.geoCheckLocations; const geoLocations = monitor?.geoCheckLocations;
const checks = checksData?.checks ?? []; const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0; const checksCount = checksData?.checksCount ?? 0;
@@ -223,7 +215,7 @@ const UptimeDetailsPage = () => {
/> />
</Stack> </Stack>
<HistogramDetails <HistogramDetails
checks={monitorData.groupedChecks} checks={monitorData?.groupedChecks || []}
range={dateRange} range={dateRange}
/> />
<ChecksTable <ChecksTable
+12 -10
View File
@@ -149,15 +149,6 @@ export interface GroupedCheck {
totalChecks: number; totalChecks: number;
} }
export interface GroupedUptimeCheck extends GroupedCheck {
avgDns: number;
avgTcp: number;
avgTls: number;
avgRequest: number;
avgFirstByte: number;
avgDownload: number;
}
export interface PageSpeedGroupedCheck { export interface PageSpeedGroupedCheck {
bucketDate: string; bucketDate: string;
performance: number; performance: number;
@@ -250,7 +241,18 @@ export interface ChecksSummary {
downChecks: number; downChecks: number;
} }
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt">; export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
originalResponseTime: number;
};
export interface HasResponseTime { export interface HasResponseTime {
responseTime: number; responseTime: number;
} }
export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
originalResponseTime: number;
};
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
+2 -2
View File
@@ -1,4 +1,4 @@
import type { GroupedCheck, CheckSnapshot, GroupedUptimeCheck } from "@/Types/Check"; import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck"; import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck"; export type { GeoContinent } from "@/Types/GeoCheck";
@@ -137,7 +137,7 @@ export interface MonitorStats {
export interface MonitorData { export interface MonitorData {
monitor: Monitor; monitor: Monitor;
groupedChecks: GroupedUptimeCheck[]; groupedChecks: GroupedCheck[];
groupedUpChecks: GroupedCheck[]; groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[];
groupedAvgResponseTime: number; groupedAvgResponseTime: number;
+44 -29
View File
@@ -1,3 +1,47 @@
const MIN_OUT = 10;
const MAX_OUT = 100;
export const normalizeResponseTimes = <T, K extends keyof T>(
checks: T[],
key: K
): (T & { normalResponseTime: number })[] => {
if (!Array.isArray(checks) || checks.length === 0)
return checks as (T & {
normalResponseTime: number;
})[];
if (checks.length === 1) {
return [
{
...checks[0],
normalResponseTime: 50,
},
];
}
const getVal = (check: T): number => {
const v = check[key] as unknown;
return typeof v === "number" && Number.isFinite(v) ? v : 0;
};
const { min, max } = checks.reduce(
(acc, check) => {
const v = getVal(check);
if (v > acc.max) acc.max = v;
if (v < acc.min) acc.min = v;
return acc;
},
{ max: -Infinity, min: Infinity }
);
const range = max - min || 1;
return checks.map((check) => ({
...check,
normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range,
}));
};
// Interpolate color between three theme colors over 0-100 range. // Interpolate color between three theme colors over 0-100 range.
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light) // 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
export const getResponseColor = ( export const getResponseColor = (
@@ -85,32 +129,3 @@ export const getResponseColor = (
} }
return normalizeToHex(safe.end); return normalizeToHex(safe.end);
}; };
const MIN_HEIGHT_PCT = 8;
const CAP_PERCENTILE = 95;
const percentile = (sortedValues: number[], p: number): number => {
const index = (p / 100) * (sortedValues.length - 1);
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= sortedValues.length) return sortedValues[lower];
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
};
export const computeBarHeights = (
checks: { responseTime?: number | null }[]
): number[] => {
const values = checks.map((check) => {
return typeof check.responseTime === "number" && Number.isFinite(check.responseTime)
? Math.max(0, check.responseTime)
: 0;
});
const sorted = values.slice().sort((a, b) => a - b);
const cap = percentile(sorted, CAP_PERCENTILE);
if (cap <= 0) return values.map(() => MIN_HEIGHT_PCT);
return values.map((value) => {
return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100));
});
};
-31
View File
@@ -20,19 +20,8 @@ export const colors = {
brandGreen: "#13715B", brandGreen: "#13715B",
brandGreenLight: "#4DAF94", brandGreenLight: "#4DAF94",
green200: "#ECF7F2", green200: "#ECF7F2",
green700: "#008300",
yellow200: "#FFF4E5", yellow200: "#FFF4E5",
yellow500: "#EDA100",
yellow600: "#C98500",
orange500: "#FFA500", orange500: "#FFA500",
orange600: "#EB6834",
orange700: "#D95926",
blue400: "#3987E5",
blue500: "#2A78D6",
aqua500: "#1BAF7A",
aqua600: "#199E70",
magenta300: "#E87BA4",
magenta400: "#D55181",
}; };
export const lightPalette = { export const lightPalette = {
@@ -49,16 +38,6 @@ export const lightPalette = {
running: colors.green200, running: colors.green200,
paused: colors.yellow200, paused: colors.yellow200,
}, },
chart: {
phases: {
dns: colors.magenta300,
tcp: colors.orange600,
tls: colors.aqua500,
request: colors.yellow500,
firstByte: colors.blue500,
download: colors.green700,
},
},
}; };
export const darkPalette = { export const darkPalette = {
@@ -79,14 +58,4 @@ export const darkPalette = {
running: alpha(colors.brandGreen, 0.18), running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18), paused: alpha(colors.orange500, 0.18),
}, },
chart: {
phases: {
dns: colors.magenta400,
tcp: colors.orange700,
tls: colors.aqua600,
request: colors.yellow600,
firstByte: colors.blue500,
download: colors.green700,
},
},
}; };
-20
View File
@@ -16,30 +16,10 @@ declare module "@mui/material/styles" {
interface Palette { interface Palette {
sidebar: { accent: string }; sidebar: { accent: string };
rowStatus: { running: string; paused: string }; rowStatus: { running: string; paused: string };
chart: {
phases: {
dns: string;
tcp: string;
tls: string;
request: string;
firstByte: string;
download: string;
};
};
} }
interface PaletteOptions { interface PaletteOptions {
sidebar?: { accent: string }; sidebar?: { accent: string };
rowStatus?: { running: string; paused: string }; rowStatus?: { running: string; paused: string };
chart?: {
phases: {
dns: string;
tcp: string;
tls: string;
request: string;
firstByte: string;
download: string;
};
};
} }
} }
+4 -4
View File
@@ -825,7 +825,7 @@
"netBytesRecv": "{{name}} - Empfangene Bytes", "netBytesRecv": "{{name}} - Empfangene Bytes",
"netBytesSent": "{{name}} - Gesendete 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.", "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": "CPU-Temperatur" "temp": "Temp."
} }
}, },
"gauges": { "gauges": {
@@ -841,13 +841,13 @@
}, },
"memory": { "memory": {
"lowerLabel": "Frei", "lowerLabel": "Frei",
"title": "Speicherauslastung", "title": "Arbeitsspeicherauslastung",
"upperLabel": "Belegt" "upperLabel": "Belegt"
} }
}, },
"statBoxes": { "statBoxes": {
"avgCpuTemperature": "Durchschn. CPU-Temperatur", "avgCpuTemperature": "Durchschn. CPU Termperatur",
"cpuFrequency": "CPU-Frequenz", "cpuFrequency": "CPU Frequenz",
"cpuLogical": "CPU (logisch)", "cpuLogical": "CPU (logisch)",
"cpuPhysical": "CPU (physisch)", "cpuPhysical": "CPU (physisch)",
"disk": "Festplatte", "disk": "Festplatte",
+2 -15
View File
@@ -68,24 +68,11 @@
"downtime": "Downtime", "downtime": "Downtime",
"high": "High", "high": "High",
"low": "Low", "low": "Low",
"responseTime": "Response time",
"timingPhases": "Timing phases",
"totalResponseTime": "Total response time",
"uptime": "Uptime" "uptime": "Uptime"
}, },
"histogram": { "histogram": {
"avg": "Avg: {{value}} ms", "avg": "Avg: {{value}} ms",
"max": "Max: {{value}} ms", "max": "Max: {{value}} ms"
"responseTime": "Response time: {{value}}",
"total": "Total: {{value}}"
},
"phases": {
"dns": "DNS",
"tcp": "TCP",
"tls": "TLS",
"request": "Request",
"firstByte": "First byte",
"download": "Download"
} }
}, },
"dialogs": { "dialogs": {
@@ -858,7 +845,7 @@
"netBytesRecv": "{{name}} - Bytes Received", "netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent", "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.", "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": "CPU temperature" "temp": "Temp"
} }
}, },
"gauges": { "gauges": {
+13 -343
View File
@@ -536,341 +536,6 @@
"updatedAt": "2026-04-15T14:30:00.000Z" "updatedAt": "2026-04-15T14:30:00.000Z"
} }
}, },
"UptimeDetails": {
"type": "object",
"properties": {
"monitorData": {
"type": "object",
"properties": {
"monitor": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
},
"url": {
"type": "string"
},
"port": {
"type": "number"
},
"isActive": {
"type": "boolean"
},
"interval": {
"type": "number"
},
"status": {
"type": "string",
"enum": ["up", "down", "paused", "initializing", "maintenance", "breached"]
},
"statusWindowSize": {
"type": "number"
},
"statusWindowThreshold": {
"type": "number"
},
"ignoreTlsErrors": {
"type": "boolean"
},
"useAdvancedMatching": {
"type": "boolean"
},
"jsonPath": {
"type": "string"
},
"expectedValue": {
"type": "string"
},
"matchMethod": {
"type": "string",
"enum": ["equal", "include", "regex"]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"]
},
"notifications": {
"type": "array",
"items": {
"type": "string"
}
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
}
},
"secret": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
"memoryAlertThreshold": {
"type": "number"
},
"diskAlertThreshold": {
"type": "number"
},
"tempAlertThreshold": {
"type": "number"
},
"selectedDisks": {
"type": "array",
"items": {
"type": "string"
}
},
"gameId": {
"type": "string"
},
"grpcServiceName": {
"type": "string"
},
"group": {
"type": "string",
"nullable": true
},
"geoCheckEnabled": {
"type": "boolean"
},
"geoCheckLocations": {
"type": "array",
"items": {
"type": "string",
"enum": ["EU", "NA", "AS", "SA", "AF", "OC"]
}
},
"geoCheckInterval": {
"type": "number"
},
"dnsServer": {
"type": "string"
},
"dnsRecordType": {
"type": "string",
"enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
},
"teamId": {
"type": "string"
},
"userId": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"lastEvaluatedAt": {
"type": "number"
},
"id": {
"type": "string"
}
},
"required": [
"name",
"type",
"url",
"isActive",
"interval",
"status",
"statusWindowSize",
"statusWindowThreshold",
"ignoreTlsErrors",
"useAdvancedMatching",
"method",
"notifications",
"tags",
"cpuAlertThreshold",
"memoryAlertThreshold",
"diskAlertThreshold",
"tempAlertThreshold",
"selectedDisks",
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"teamId",
"userId",
"createdAt",
"updatedAt",
"lastEvaluatedAt",
"id"
],
"additionalProperties": {
"nullable": true
}
},
"groupedChecks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"bucketDate": {
"type": "string"
},
"avgResponseTime": {
"type": "number"
},
"totalChecks": {
"type": "number"
},
"avgDns": {
"type": "number"
},
"avgTcp": {
"type": "number"
},
"avgTls": {
"type": "number"
},
"avgRequest": {
"type": "number"
},
"avgFirstByte": {
"type": "number"
},
"avgDownload": {
"type": "number"
}
},
"required": [
"bucketDate",
"avgResponseTime",
"totalChecks",
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload"
]
}
},
"groupedUpChecks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"bucketDate": {
"type": "string"
},
"avgResponseTime": {
"type": "number"
},
"totalChecks": {
"type": "number"
}
},
"required": ["bucketDate", "avgResponseTime", "totalChecks"]
}
},
"groupedDownChecks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"bucketDate": {
"type": "string"
},
"avgResponseTime": {
"type": "number"
},
"totalChecks": {
"type": "number"
}
},
"required": ["bucketDate", "avgResponseTime", "totalChecks"]
}
},
"groupedAvgResponseTime": {
"type": "number"
},
"groupedUptimePercentage": {
"type": "number"
}
},
"required": ["monitor", "groupedChecks", "groupedUpChecks", "groupedDownChecks", "groupedAvgResponseTime", "groupedUptimePercentage"]
},
"monitorStats": {
"type": "object",
"nullable": true,
"properties": {
"id": {
"type": "string"
},
"monitorId": {
"type": "string"
},
"avgResponseTime": {
"type": "number"
},
"maxResponseTime": {
"type": "number"
},
"totalChecks": {
"type": "number"
},
"totalUpChecks": {
"type": "number"
},
"totalDownChecks": {
"type": "number"
},
"uptimePercentage": {
"type": "number"
},
"lastCheckTimestamp": {
"type": "number"
},
"lastResponseTime": {
"type": "number"
},
"timeOfLastFailure": {
"type": "number"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"id",
"monitorId",
"avgResponseTime",
"maxResponseTime",
"totalChecks",
"totalUpChecks",
"totalDownChecks",
"uptimePercentage",
"lastCheckTimestamp",
"lastResponseTime",
"createdAt",
"updatedAt"
]
}
},
"required": ["monitorData", "monitorStats"]
},
"NotificationChannelBody": { "NotificationChannelBody": {
"oneOf": [ "oneOf": [
{ {
@@ -2761,9 +2426,6 @@
"minLength": 8, "minLength": 8,
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
}, },
"deleteProfileImage": {
"type": "boolean"
},
"profileImage": { "profileImage": {
"type": "string", "type": "string",
"format": "binary" "format": "binary"
@@ -4085,7 +3747,7 @@
{ {
"schema": { "schema": {
"type": "string", "type": "string",
"enum": ["automatic", "manual"] "enum": ["manual", "automatic"]
}, },
"required": false, "required": false,
"name": "resolutionType", "name": "resolutionType",
@@ -6351,6 +6013,14 @@
"required": true, "required": true,
"name": "dateRange", "name": "dateRange",
"in": "query" "in": "query"
},
{
"schema": {
"type": "boolean"
},
"required": false,
"name": "normalize",
"in": "query"
} }
], ],
"responses": { "responses": {
@@ -6369,10 +6039,10 @@
"type": "string" "type": "string"
}, },
"data": { "data": {
"$ref": "#/components/schemas/UptimeDetails" "nullable": true
} }
}, },
"required": ["success", "msg", "data"] "required": ["success", "msg"]
} }
} }
} }
@@ -10868,7 +10538,7 @@
}, },
"theme": { "theme": {
"type": "string", "type": "string",
"enum": ["refined", "modern", "bold", "editorial", "minimal"] "enum": ["refined", "modern", "bold", "editorial"]
}, },
"themeMode": { "themeMode": {
"type": "string", "type": "string",
@@ -11086,7 +10756,7 @@
}, },
"theme": { "theme": {
"type": "string", "type": "string",
"enum": ["refined", "modern", "bold", "editorial", "minimal"] "enum": ["refined", "modern", "bold", "editorial"]
}, },
"themeMode": { "themeMode": {
"type": "string", "type": "string",
+1 -4
View File
@@ -16,7 +16,6 @@ import {
getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation, getHardwareDetailsByIdQueryValidation,
monitorResponseSchema, monitorResponseSchema,
uptimeDetailsResponseSchema,
} from "@/api/validation/monitorValidation.js"; } from "@/api/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js"; import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
@@ -82,8 +81,6 @@ registry.registerPath({
responses: { "200": okJson(z.array(z.string())), ...standardErrors }, responses: { "200": okJson(z.array(z.string())), ...standardErrors },
}); });
const uptimeDetailsObject = uptimeDetailsResponseSchema.openapi("UptimeDetails");
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/monitors/uptime/details/{monitorId}", path: "/monitors/uptime/details/{monitorId}",
@@ -91,7 +88,7 @@ registry.registerPath({
summary: "Get uptime details for a monitor", summary: "Get uptime details for a monitor",
security: bearer, security: bearer,
request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation }, request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation },
responses: { "200": okJson(uptimeDetailsObject), ...standardErrors }, responses: { "200": okUnknown, ...standardErrors },
}); });
registry.registerPath({ registry.registerPath({
-1
View File
@@ -15,7 +15,6 @@
"worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js", "worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js",
"clean": "rm -rf dist", "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", "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", "build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
"lint": "eslint .", "lint": "eslint .",
"lint-fix": "eslint --fix .", "lint-fix": "eslint --fix .",
@@ -22,6 +22,8 @@ import {
} from "@/api/validation/userValidation.js"; } from "@/api/validation/userValidation.js";
import { IUserService } from "@/domain/users/user.service.js"; import { IUserService } from "@/domain/users/user.service.js";
const SERVICE_NAME = "authController";
export interface IAuthController { export interface IAuthController {
registerUser: RequestHandler; registerUser: RequestHandler;
createUser: RequestHandler; createUser: RequestHandler;
@@ -40,12 +42,18 @@ export interface IAuthController {
} }
class AuthController implements IAuthController { class AuthController implements IAuthController {
static SERVICE_NAME = SERVICE_NAME;
private userService: IUserService; private userService: IUserService;
constructor(userService: IUserService) { constructor(userService: IUserService) {
this.userService = userService; this.userService = userService;
} }
get serviceName() {
return AuthController.SERVICE_NAME;
}
registerUser = catchAsync(async (req: Request, res: Response) => { registerUser = catchAsync(async (req: Request, res: Response) => {
const newUser = req.body.user; const newUser = req.body.user;
const newUserToken = req.body.token; const newUserToken = req.body.token;
@@ -11,7 +11,10 @@ import {
import { ICheckService } from "@/domain/checks/check.service.js"; import { ICheckService } from "@/domain/checks/check.service.js";
import { requireTeamId } from "@/api/controllers/controllerUtils.js"; import { requireTeamId } from "@/api/controllers/controllerUtils.js";
const SERVICE_NAME = "checkController";
export interface ICheckController { export interface ICheckController {
serviceName: string;
getChecksByMonitor: RequestHandler; getChecksByMonitor: RequestHandler;
getChecksByTeam: RequestHandler; getChecksByTeam: RequestHandler;
getChecksSummaryByTeamId: RequestHandler; getChecksSummaryByTeamId: RequestHandler;
@@ -20,11 +23,17 @@ export interface ICheckController {
} }
class CheckController implements ICheckController { class CheckController implements ICheckController {
static SERVICE_NAME = SERVICE_NAME;
private checkService: ICheckService; private checkService: ICheckService;
constructor(checkService: ICheckService) { constructor(checkService: ICheckService) {
this.checkService = checkService; this.checkService = checkService;
} }
get serviceName() {
return CheckController.SERVICE_NAME;
}
getChecksByMonitor = catchAsync(async (req: Request, res: Response) => { getChecksByMonitor = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getChecksParamValidation.parse(req.params); const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query); const validatedQuery = getChecksQueryValidation.parse(req.query);
+19 -1
View File
@@ -1,5 +1,5 @@
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import { UserRole } from "@/domain/users/user.type.js"; import { UserRole } from "@/domain/users/user.type.js";
import sslChecker, { SSLDetails } from "ssl-checker"; import sslChecker, { SSLDetails } from "ssl-checker";
type SSLCheckerType = typeof sslChecker; type SSLCheckerType = typeof sslChecker;
@@ -54,6 +54,24 @@ export const optionalNumber = (value: unknown, fieldName: string): number | unde
throw new AppError({ message: `${fieldName} must be a number`, status: 400 }); 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 => { export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseSingle = (input: unknown): MonitorType => { const parseSingle = (input: unknown): MonitorType => {
if (typeof input !== "string") { if (typeof input !== "string") {
@@ -2,17 +2,25 @@ import { IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js";
import { Request, Response, RequestHandler } from "express"; import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js"; import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "diagnosticController";
export interface IDiagnosticController { export interface IDiagnosticController {
getSystemStats: RequestHandler; getSystemStats: RequestHandler;
} }
class DiagnosticController implements IDiagnosticController { class DiagnosticController implements IDiagnosticController {
static SERVICE_NAME = SERVICE_NAME;
private diagnosticService: IDiagnosticService; private diagnosticService: IDiagnosticService;
constructor(diagnosticService: IDiagnosticService) { constructor(diagnosticService: IDiagnosticService) {
this.diagnosticService = diagnosticService; this.diagnosticService = diagnosticService;
} }
get serviceName() {
return DiagnosticController.SERVICE_NAME;
}
getSystemStats = catchAsync(async (req: Request, res: Response) => { getSystemStats = catchAsync(async (req: Request, res: Response) => {
const diagnostics = await this.diagnosticService.getSystemStats(); const diagnostics = await this.diagnosticService.getSystemStats();
return res.status(200).json({ return res.status(200).json({
@@ -4,15 +4,23 @@ import { getChecksParamValidation, getChecksQueryValidation } from "@/api/valida
import type { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js"; import type { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
import { requireTeamId } from "./controllerUtils.js"; import { requireTeamId } from "./controllerUtils.js";
const SERVICE_NAME = "geoCheckController";
export interface IGeoCheckController { export interface IGeoCheckController {
getGeoChecksByMonitor: RequestHandler; getGeoChecksByMonitor: RequestHandler;
} }
class GeoCheckController implements IGeoCheckController { class GeoCheckController implements IGeoCheckController {
static SERVICE_NAME = SERVICE_NAME;
private geoChecksService: IGeoChecksService; private geoChecksService: IGeoChecksService;
constructor(geoChecksService: IGeoChecksService) { constructor(geoChecksService: IGeoChecksService) {
this.geoChecksService = geoChecksService; this.geoChecksService = geoChecksService;
} }
get serviceName() {
return GeoCheckController.SERVICE_NAME;
}
getGeoChecksByMonitor = catchAsync(async (req: Request, res: Response) => { getGeoChecksByMonitor = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getChecksParamValidation.parse(req.params); const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query); const validatedQuery = getChecksQueryValidation.parse(req.query);
@@ -3,6 +3,7 @@ import { catchAsync } from "@/utils/catchAsync.js";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/api/validation/authValidation.js"; import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/api/validation/authValidation.js";
import { requireFirstName, requireTeamId, requireUserRoles } from "@/api/controllers/controllerUtils.js"; import { requireFirstName, requireTeamId, requireUserRoles } from "@/api/controllers/controllerUtils.js";
import { IInviteService } from "@/domain/invites/invite.service.js"; import { IInviteService } from "@/domain/invites/invite.service.js";
const SERVICE_NAME = "inviteController";
export interface IInviteController { export interface IInviteController {
getInviteToken: RequestHandler; getInviteToken: RequestHandler;
@@ -11,11 +12,16 @@ export interface IInviteController {
} }
class InviteController implements IInviteController { class InviteController implements IInviteController {
static SERVICE_NAME = SERVICE_NAME;
private inviteService: IInviteService; private inviteService: IInviteService;
constructor(inviteService: IInviteService) { constructor(inviteService: IInviteService) {
this.inviteService = inviteService; this.inviteService = inviteService;
} }
get serviceName() {
return InviteController.SERVICE_NAME;
}
getInviteToken = catchAsync(async (req: Request, res: Response) => { getInviteToken = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId); const teamId = requireTeamId(req.user?.teamId);
const userRoles = requireUserRoles(req.user?.role); const userRoles = requireUserRoles(req.user?.role);
@@ -2,15 +2,21 @@ import { ILogger } from "@/utils/logger.js";
import { Request, Response, RequestHandler } from "express"; import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js"; import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "LogController";
export interface ILogController { export interface ILogController {
getLogs: RequestHandler; getLogs: RequestHandler;
} }
class LogController { class LogController {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger; private logger: ILogger;
constructor(logger: ILogger) { constructor(logger: ILogger) {
this.logger = logger; this.logger = logger;
} }
get serviceName() {
return LogController.SERVICE_NAME;
}
getLogs = catchAsync(async (req: Request, res: Response) => { getLogs = catchAsync(async (req: Request, res: Response) => {
const logs = this.logger.getLogs(); const logs = this.logger.getLogs();
@@ -23,6 +23,8 @@ import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/domain/monitors/monitor.service.js"; import { IMonitorService } from "@/domain/monitors/monitor.service.js";
import { INotificationsService } from "@/domain/notifications/notification.service.js"; import { INotificationsService } from "@/domain/notifications/notification.service.js";
const SERVICE_NAME = "monitorController";
export interface IMonitorController { export interface IMonitorController {
getMonitorCertificate: RequestHandler; getMonitorCertificate: RequestHandler;
getUptimeDetailsById: RequestHandler; getUptimeDetailsById: RequestHandler;
@@ -45,6 +47,8 @@ export interface IMonitorController {
updateNotifications: RequestHandler; updateNotifications: RequestHandler;
} }
class MonitorController implements IMonitorController { class MonitorController implements IMonitorController {
static SERVICE_NAME = SERVICE_NAME;
private monitorService: IMonitorService; private monitorService: IMonitorService;
private notificationsService: INotificationsService; private notificationsService: INotificationsService;
@@ -53,6 +57,10 @@ class MonitorController implements IMonitorController {
this.notificationsService = notificationsService; this.notificationsService = notificationsService;
} }
get serviceName() {
return MonitorController.SERVICE_NAME;
}
getMonitorCertificate = catchAsync(async (req: Request, res: Response) => { getMonitorCertificate = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getCertificateParamValidation.parse(req.params); const validatedParams = getCertificateParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId); const teamId = requireTeamId(req.user?.teamId);
@@ -3,6 +3,8 @@ import { getQueueJobsQueryValidation } from "@/api/validation/queueValidation.js
import { Request, Response, RequestHandler } from "express"; import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js"; import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "JobQueueController";
export interface IJobQueueController { export interface IJobQueueController {
getMetrics: RequestHandler; getMetrics: RequestHandler;
getJobs: RequestHandler; getJobs: RequestHandler;
@@ -11,8 +13,13 @@ export interface IJobQueueController {
} }
class JobQueueController implements IJobQueueController { class JobQueueController implements IJobQueueController {
static SERVICE_NAME = SERVICE_NAME;
constructor(private scheduler: IJobScheduler) {} constructor(private scheduler: IJobScheduler) {}
get serviceName() {
return JobQueueController.SERVICE_NAME;
}
getMetrics = catchAsync(async (req: Request, res: Response) => { getMetrics = catchAsync(async (req: Request, res: Response) => {
const metrics = await this.scheduler.getMetrics(); const metrics = await this.scheduler.getMetrics();
res.status(200).json({ res.status(200).json({
@@ -7,13 +7,17 @@ import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"
import { IEmailService } from "@/service/emailService.js"; import { IEmailService } from "@/service/emailService.js";
import { Settings } from "@/domain/app-settings/app-settings.type.js"; import { Settings } from "@/domain/app-settings/app-settings.type.js";
const SERVICE_NAME = "SettingsController";
export interface ISettingsController { export interface ISettingsController {
serviceName: string;
getAppSettings: RequestHandler; getAppSettings: RequestHandler;
updateAppSettings: RequestHandler; updateAppSettings: RequestHandler;
sendTestEmail: RequestHandler; sendTestEmail: RequestHandler;
} }
class SettingsController implements ISettingsController { class SettingsController implements ISettingsController {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: ISettingsService; private settingsService: ISettingsService;
private emailService: IEmailService; private emailService: IEmailService;
constructor(settingsService: ISettingsService, emailService: IEmailService) { constructor(settingsService: ISettingsService, emailService: IEmailService) {
@@ -21,6 +25,10 @@ class SettingsController implements ISettingsController {
this.emailService = emailService; this.emailService = emailService;
} }
get serviceName() {
return SettingsController.SERVICE_NAME;
}
buildAppSettings = (dbSettings: Settings) => { buildAppSettings = (dbSettings: Settings) => {
const sanitizedSettings: Record<string, unknown> = { ...dbSettings }; const sanitizedSettings: Record<string, unknown> = { ...dbSettings };
delete sanitizedSettings.version; delete sanitizedSettings.version;
@@ -13,7 +13,10 @@ import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.
import { IStatusPageService } from "@/domain/status-pages/status-page.service.js"; import { IStatusPageService } from "@/domain/status-pages/status-page.service.js";
import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js"; import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js";
const SERVICE_NAME = "statusPageController";
export interface IStatusPageController { export interface IStatusPageController {
readonly serviceName: string;
createStatusPage: RequestHandler; createStatusPage: RequestHandler;
updateStatusPage: RequestHandler; updateStatusPage: RequestHandler;
getStatusPageByUrl: RequestHandler; getStatusPageByUrl: RequestHandler;
@@ -23,11 +26,16 @@ export interface IStatusPageController {
} }
class StatusPageController implements IStatusPageController { class StatusPageController implements IStatusPageController {
static SERVICE_NAME = SERVICE_NAME;
private statusPageService: IStatusPageService; private statusPageService: IStatusPageService;
constructor(statusPageService: IStatusPageService) { constructor(statusPageService: IStatusPageService) {
this.statusPageService = statusPageService; this.statusPageService = statusPageService;
} }
get serviceName() {
return StatusPageController.SERVICE_NAME;
}
createStatusPage = catchAsync(async (req: Request, res: Response) => { createStatusPage = catchAsync(async (req: Request, res: Response) => {
createStatusPageBodyValidation.parse(req.body); createStatusPageBodyValidation.parse(req.body);
if (req.file) { if (req.file) {
+8 -9
View File
@@ -1,8 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { booleanCoercion } from "./shared.js"; import { booleanCoercion } from "./shared.js";
import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js"; import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js";
import { MonitorTypes } from "@/domain/monitors/monitor.type.js"; import { MonitorTypes } from "@/domain/monitors/monitor.types.js";
import { CheckFilters, DateRanges, SortOrders } from "@/types/query.js";
//**************************************** //****************************************
// Check Validations // Check Validations
@@ -14,10 +13,10 @@ export const getChecksParamValidation = z.object({
export const getChecksQueryValidation = z.object({ export const getChecksQueryValidation = z.object({
type: z.enum(MonitorTypes).optional(), type: z.enum(MonitorTypes).optional(),
sortOrder: z.enum(SortOrders), sortOrder: z.enum(["asc", "desc"]),
limit: z.coerce.number().optional(), limit: z.coerce.number().optional(),
dateRange: z.enum(DateRanges), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
filter: z.enum(CheckFilters).optional(), filter: z.enum(["all", "up", "down", "resolve"]).optional(),
ack: booleanCoercion.optional(), ack: booleanCoercion.optional(),
page: z.coerce.number(), page: z.coerce.number(),
rowsPerPage: z.coerce.number(), rowsPerPage: z.coerce.number(),
@@ -26,10 +25,10 @@ export const getChecksQueryValidation = z.object({
}); });
export const getTeamChecksQueryValidation = z.object({ export const getTeamChecksQueryValidation = z.object({
sortOrder: z.enum(SortOrders), sortOrder: z.enum(["asc", "desc"]),
limit: z.coerce.number().optional(), limit: z.coerce.number().optional(),
dateRange: z.enum(DateRanges), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
filter: z.enum(CheckFilters).optional(), filter: z.enum(["all", "up", "down", "resolve"]).optional(),
ack: booleanCoercion.optional(), ack: booleanCoercion.optional(),
page: z.coerce.number(), page: z.coerce.number(),
rowsPerPage: z.coerce.number(), rowsPerPage: z.coerce.number(),
@@ -40,5 +39,5 @@ export const deleteChecksParamValidation = z.object({
}); });
export const getChecksSummaryByTeamIdQueryValidation = z.object({ export const getChecksSummaryByTeamIdQueryValidation = z.object({
dateRange: z.enum(DateRanges).optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
}); });
@@ -1,20 +1,18 @@
import { z } from "zod"; import { z } from "zod";
import { booleanCoercion } from "./shared.js"; import { booleanCoercion } from "./shared.js";
import { DateRanges, SortOrders } from "@/types/query.js";
import { IncidentResolutionTypes } from "@/domain/incidents/incident.type.js";
//**************************************** //****************************************
// Incident Validations // Incident Validations
//**************************************** //****************************************
export const getIncidentsByTeamQueryValidation = z.object({ export const getIncidentsByTeamQueryValidation = z.object({
sortOrder: z.enum(SortOrders), sortOrder: z.enum(["asc", "desc"]),
dateRange: z.enum(DateRanges).default("all"), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).default("all"),
page: z.coerce.number().int().min(0), page: z.coerce.number().int().min(0),
rowsPerPage: z.coerce.number().int().min(1), rowsPerPage: z.coerce.number().int().min(1),
status: booleanCoercion.optional(), status: booleanCoercion.optional(),
monitorId: z.string().optional(), monitorId: z.string().optional(),
resolutionType: z.enum(IncidentResolutionTypes).optional(), resolutionType: z.enum(["manual", "automatic"]).optional(),
}); });
export const getIncidentSummaryQueryValidation = z.object({ export const getIncidentSummaryQueryValidation = z.object({
@@ -1,7 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { booleanCoercion } from "./shared.js"; import { booleanCoercion } from "./shared.js";
import { DurationUnits } from "@/domain/maintenance-windows/maintenance-window.type.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()); const dateToString = z.coerce.date().transform((d) => d.toISOString());
@@ -50,7 +49,7 @@ export const getMaintenanceWindowsByTeamIdQueryValidation = z.object({
page: z.coerce.number().optional(), page: z.coerce.number().optional(),
rowsPerPage: z.coerce.number().optional(), rowsPerPage: z.coerce.number().optional(),
field: z.string().optional(), field: z.string().optional(),
order: z.enum(SortOrders).optional(), order: z.enum(["asc", "desc"]).optional(),
}); });
export const getMaintenanceWindowsByMonitorIdParamValidation = z.object({ export const getMaintenanceWindowsByMonitorIdParamValidation = z.object({
+9 -57
View File
@@ -10,8 +10,7 @@ import {
MonitorStatuses, MonitorStatuses,
MonitorTypes, MonitorTypes,
PageSpeedStrategies, PageSpeedStrategies,
} from "@/domain/monitors/monitor.type.js"; } from "@/domain/monitors/monitor.types.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" }); const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -21,10 +20,11 @@ export const getMonitorByIdParamValidation = z.object({
export const getMonitorByIdQueryValidation = z.object({ export const getMonitorByIdQueryValidation = z.object({
status: booleanCoercion.optional(), status: booleanCoercion.optional(),
sortOrder: z.enum(SortOrders).optional(), sortOrder: z.enum(["asc", "desc"]).optional(),
limit: z.coerce.number().optional(), limit: z.coerce.number().optional(),
dateRange: z.enum(DateRanges).optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
numToDisplay: z.coerce.number().optional(), numToDisplay: z.coerce.number().optional(),
normalize: booleanCoercion.optional(),
continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(), continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(),
}); });
@@ -42,7 +42,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({
rowsPerPage: z.coerce.number().int().min(1).max(100).optional(), rowsPerPage: z.coerce.number().int().min(1).max(100).optional(),
filter: z.union([z.string(), z.literal("")]).optional(), filter: z.union([z.string(), z.literal("")]).optional(),
field: z.string().optional(), field: z.string().optional(),
order: z.enum(SortOrders).optional(), order: z.enum(["asc", "desc"]).optional(),
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
tags: z.union([z.string(), z.array(z.string())]).optional(), tags: z.union([z.string(), z.array(z.string())]).optional(),
explain: booleanCoercion.optional(), explain: booleanCoercion.optional(),
@@ -195,7 +195,8 @@ export const getUptimeDetailsByIdParamValidation = z.object({
}); });
export const getUptimeDetailsByIdQueryValidation = z.object({ export const getUptimeDetailsByIdQueryValidation = z.object({
dateRange: z.enum(DateRanges), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
normalize: booleanCoercion.optional(),
}); });
const importedMonitorSchema = z const importedMonitorSchema = z
@@ -205,7 +206,7 @@ const importedMonitorSchema = z
teamId: z.string().optional(), teamId: z.string().optional(),
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
description: z.union([z.string(), z.literal("")]).optional(), description: z.union([z.string(), z.literal("")]).optional(),
status: z.enum(MonitorStatuses).default("initializing"), status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
statusWindow: z.array(z.boolean()).default([]), statusWindow: z.array(z.boolean()).default([]),
statusWindowSize: z.number().min(1).max(20).default(5), statusWindowSize: z.number().min(1).max(20).default(5),
statusWindowThreshold: z.number().min(1).max(100).default(60), statusWindowThreshold: z.number().min(1).max(100).default(60),
@@ -262,7 +263,7 @@ export const getHardwareDetailsByIdParamValidation = z.object({
}); });
export const getHardwareDetailsByIdQueryValidation = z.object({ export const getHardwareDetailsByIdQueryValidation = z.object({
dateRange: z.enum(DateRanges).optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
}); });
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with // Canonical monitor shape returned by /monitors endpoints. Keep aligned with
@@ -310,52 +311,3 @@ export const monitorResponseSchema = z
lastEvaluatedAt: z.number(), lastEvaluatedAt: z.number(),
}) })
.passthrough(); .passthrough();
// Grouped-check buckets returned by GET /monitors/uptime/details/{monitorId}. Keep
// aligned with GroupedCheck / GroupedUptimeCheck in domain/checks/check.type.ts.
export const groupedCheckResponseSchema = z.object({
bucketDate: z.string(),
avgResponseTime: z.number(),
totalChecks: z.number(),
});
export const groupedUptimeCheckResponseSchema = groupedCheckResponseSchema.extend({
avgDns: z.number(),
avgTcp: z.number(),
avgTls: z.number(),
avgRequest: z.number(),
avgFirstByte: z.number(),
avgDownload: z.number(),
});
// Keep aligned with MonitorStats in domain/monitor-stats/monitor-stats.type.ts.
export const monitorStatsResponseSchema = z.object({
id: z.string(),
monitorId: z.string(),
avgResponseTime: z.number(),
maxResponseTime: z.number(),
totalChecks: z.number(),
totalUpChecks: z.number(),
totalDownChecks: z.number(),
uptimePercentage: z.number(),
lastCheckTimestamp: z.number(),
lastResponseTime: z.number(),
timeOfLastFailure: z.number().optional(),
createdAt: z.string(),
updatedAt: z.string(),
});
// Response of GET /monitors/uptime/details/{monitorId}. Keep aligned with
// UptimeDetailsResult in domain/monitors/monitor.type.ts; the monitor here is the
// repository's domain entity, which serializes `id` rather than `_id`.
export const uptimeDetailsResponseSchema = z.object({
monitorData: z.object({
monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }),
groupedChecks: z.array(groupedUptimeCheckResponseSchema),
groupedUpChecks: z.array(groupedCheckResponseSchema),
groupedDownChecks: z.array(groupedCheckResponseSchema),
groupedAvgResponseTime: z.number(),
groupedUptimePercentage: z.number(),
}),
monitorStats: monitorStatsResponseSchema.nullable(),
});
@@ -3,7 +3,6 @@ import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { cssReferencesExternalResource } from "@/utils/customCss.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js";
import { ImageMimeTypes } from "@/types/upload.js";
//**************************************** //****************************************
// Status Page Validations // Status Page Validations
@@ -68,7 +67,7 @@ export const imageValidation = z
fieldname: z.string().min(1, "Field name is required"), fieldname: z.string().min(1, "Field name is required"),
originalname: z.string().min(1, "Original name is required"), originalname: z.string().min(1, "Original name is required"),
encoding: z.string().min(1, "Encoding is required"), encoding: z.string().min(1, "Encoding is required"),
mimetype: z.enum(ImageMimeTypes, { mimetype: z.enum(["image/jpeg", "image/png", "image/jpg"], {
message: "File must be a valid image (jpeg, jpg, or png)", message: "File must be a valid image (jpeg, jpg, or png)",
}), }),
size: z.number().max(3145728, "File size must be less than 3MB"), size: z.number().max(3145728, "File size must be less than 3MB"),
+1 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { UserRoles } from "@/domain/users/user.type.js"; import { UserRoles } from "@/domain/users/user.type.js";
import { nameValidation, lowercaseEmailValidation, passwordValidation, booleanCoercion } from "@/api/validation/shared.js"; import { nameValidation, lowercaseEmailValidation, passwordValidation } from "./shared.js";
//**************************************** //****************************************
// User Validations // User Validations
@@ -46,7 +46,6 @@ export const editUserBodyValidation = z
profilePicture: z.string().optional(), profilePicture: z.string().optional(),
password: passwordValidation.optional(), password: passwordValidation.optional(),
newPassword: passwordValidation.optional(), newPassword: passwordValidation.optional(),
deleteProfileImage: booleanCoercion.optional(),
}) })
// Changing a password requires both the current password and the new one. // 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 // The service only runs the change-password flow when both are present, so
+15 -20
View File
@@ -21,7 +21,6 @@ import { GlobalPingService } from "@/service/globalPingService.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
// Notification providers // Notification providers
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
import { WebhookProvider } from "@/domain/notifications/providers/webhook.js"; import { WebhookProvider } from "@/domain/notifications/providers/webhook.js";
import { SlackProvider } from "@/domain/notifications/providers/slack.js"; import { SlackProvider } from "@/domain/notifications/providers/slack.js";
import { EmailProvider } from "@/domain/notifications/providers/email.js"; import { EmailProvider } from "@/domain/notifications/providers/email.js";
@@ -126,7 +125,7 @@ export const buildShared = async ({
const checksRepository = new MongoChecksRepository(logger); const checksRepository = new MongoChecksRepository(logger);
const incidentsRepository = new MongoIncidentsRepository(); const incidentsRepository = new MongoIncidentsRepository();
const usersRepository = new MongoUsersRepository(); const usersRepository = new MongoUsersRepository();
const geoChecksRepository = new MongoGeoChecksRepository(); const geoChecksRepository = new MongoGeoChecksRepository(logger);
const notificationsRepository = new MongoNotificationsRepository(); const notificationsRepository = new MongoNotificationsRepository();
const jobsRepository = new MongoJobsRepository(workerId); const jobsRepository = new MongoJobsRepository(workerId);
const queueWorkersRepository = new MongoQueueWorkersRepository(); const queueWorkersRepository = new MongoQueueWorkersRepository();
@@ -168,28 +167,24 @@ export const buildShared = async ({
const twilioProvider = new TwilioProvider(logger); const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger); const ntfyProvider = new NtfyProvider(logger);
const notificationProviders: NotificationProviderRegistry = { const notificationsService = new NotificationsService(
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, notificationsRepository,
monitorsRepository, monitorsRepository,
providers: notificationProviders, webhookProvider,
emailProvider,
slackProvider,
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
ntfyProvider,
settingsService, settingsService,
logger, logger,
notificationMessageBuilder, notificationMessageBuilder
}); );
const sharedServices: SharedServices = { const sharedServices: SharedServices = {
logger, logger,
+8 -8
View File
@@ -131,9 +131,9 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
geoChecksRepository geoChecksRepository
); );
const worker = await DBQueueWorker.create({ const worker = await DBQueueWorker.create(
logger, logger,
isDbConnected: () => mongoose.connection.readyState === 1, () => mongoose.connection.readyState === 1,
jobsRepository, jobsRepository,
monitorsRepository, monitorsRepository,
checksRepository, checksRepository,
@@ -142,13 +142,13 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
checkProducer, checkProducer,
checkEvaluator, checkEvaluator,
geoCheckPipeline, geoCheckPipeline,
dispatcher: reactorDispatcher, reactorDispatcher,
helper: workerHelper, workerHelper,
queueWorkersRepository, queueWorkersRepository,
queueMode: envSettings.queueMode, envSettings.queueMode,
queuePrimaryProcesses: envSettings.queuePrimaryProcesses, envSettings.queuePrimaryProcesses,
workerId, workerId
}); );
return { worker, networkService, bufferService, statusService }; return { worker, networkService, bufferService, statusService };
}; };
+4
View File
@@ -21,6 +21,10 @@ class MongoDB implements IDb<Mongoose> {
this.db = null; this.db = null;
} }
get serviceName() {
return MongoDB.SERVICE_NAME;
}
connect = async () => { connect = async () => {
try { try {
const connectionString = this.envSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; const connectionString = this.envSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db";
@@ -1,209 +0,0 @@
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,7 +7,6 @@ import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArra
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js";
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js"; import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js";
import type { ILogger } from "@/utils/logger.js"; import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = { type MigrationEntry = {
@@ -24,7 +23,6 @@ const migrations: MigrationEntry[] = [
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
{ name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray },
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt }, { name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
{ name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings },
]; ];
const runMigrations = async (logger: ILogger) => { const runMigrations = async (logger: ILogger) => {
@@ -20,6 +20,8 @@ export type EnvConfig = {
}; };
export interface ISettingsService { export interface ISettingsService {
readonly serviceName: string;
loadSettings(): EnvConfig;
getSettings(): EnvConfig; getSettings(): EnvConfig;
areStatusPageThemesEnabled(): boolean; areStatusPageThemesEnabled(): boolean;
getDBSettings(): Promise<Settings>; getDBSettings(): Promise<Settings>;
@@ -55,6 +57,14 @@ export class SettingsService implements ISettingsService {
this.settingsRepository = settingsRepository; this.settingsRepository = settingsRepository;
} }
get serviceName() {
return SettingsService.SERVICE_NAME;
}
loadSettings() {
return this.settings;
}
getSettings() { getSettings() {
return this.settings; return this.settings;
} }
@@ -1,22 +1,9 @@
import { CheckModel } from "@/domain/checks/check.model.js"; import { CheckModel } from "@/domain/checks/check.model.js";
import mongoose from "mongoose"; import mongoose from "mongoose";
import type { HardwareDiskStats, HardwareNetStats } from "@/domain/checks/check.type.js";
type DateRange = { start: Date; end: Date }; type DateRange = { start: Date; end: Date };
type HardwareUpChecks = { totalChecks: number }; 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 = [ const NET_RATE_FIELDS = [
["bytesSentPerSecond", "bytes_sent"], ["bytesSentPerSecond", "bytes_sent"],
["deltaBytesRecv", "bytes_recv"], ["deltaBytesRecv", "bytes_recv"],
@@ -83,13 +70,13 @@ export const getHardwareUpChecks = async (monitorId: string, dates: DateRange):
return { totalChecks: count }; return { totalChecks: count };
}; };
export const getHardwareStats = async (monitorId: string, dates: DateRange, dateString: string): Promise<HardwareStatsBucket[]> => { export const getHardwareStats = async (monitorId: string, dates: DateRange, dateString: string) => {
const netProjection = { const netProjection = {
name: { $arrayElemAt: ["$sampleDoc.net.name", "$$nIdx"] }, name: { $arrayElemAt: ["$sampleDoc.net.name", "$$nIdx"] },
...Object.fromEntries(NET_RATE_FIELDS.map(([outputKey, sourceField]) => [outputKey, netRatePerSecond(sourceField)])), ...Object.fromEntries(NET_RATE_FIELDS.map(([outputKey, sourceField]) => [outputKey, netRatePerSecond(sourceField)])),
}; };
return await CheckModel.aggregate<HardwareStatsBucket>([ return await CheckModel.aggregate([
{ {
$match: { $match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId), "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+1 -1
View File
@@ -1,5 +1,5 @@
import { Schema, model, Types } from "mongoose"; import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js"; import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import type { import type {
Check, Check,
CheckAudits, CheckAudits,
@@ -6,9 +6,8 @@ import type {
PageSpeedChecksResult, PageSpeedChecksResult,
UptimeChecksResult, UptimeChecksResult,
} from "@/domain/checks/check.type.js"; } from "@/domain/checks/check.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js"; import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { LatestChecksMap } from "@/domain/checks/check.repository.mongo.js"; import type { LatestChecksMap } from "@/domain/checks/check.repository.mongo.js";
import { CheckFilter, DateRange } from "@/types/query.js";
export interface IChecksRepository { export interface IChecksRepository {
// create // create
@@ -20,27 +19,29 @@ export interface IChecksRepository {
findByMonitorId( findByMonitorId(
monitorId: string, monitorId: string,
sortOrder: string, sortOrder: string,
dateRange: DateRange, dateRange: string,
filter: string | undefined,
page: number, page: number,
rowsPerPage: number, rowsPerPage: number,
status: boolean | undefined, status: boolean | undefined
filter?: CheckFilter
): Promise<ChecksQueryResult>; ): Promise<ChecksQueryResult>;
findByTeamId( findByTeamId(
sortOrder: string, sortOrder: string,
dateRange: DateRange, dateRange: string,
filter: string | undefined,
page: number, page: number,
rowsPerPage: number, rowsPerPage: number,
teamId: string, teamId: string
filter?: CheckFilter
): Promise<ChecksQueryResult>; ): Promise<ChecksQueryResult>;
findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>; findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>;
findByDateRangeAndMonitorId( findByDateRangeAndMonitorId(
monitorId: string, monitorId: string,
dateRange: DateRange, startDate: Date,
endDate: Date,
dateString: string,
options?: { type?: MonitorType } options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>; ): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>; findSummaryByTeamId(teamId: string, dateRange: string): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
// update // update
//delete //delete
@@ -11,19 +11,15 @@ import type {
CheckMetadata, CheckMetadata,
CheckNetworkInterfaceInfo, CheckNetworkInterfaceInfo,
GotTimings, GotTimings,
HardwareCheckStats,
} from "@/domain/checks/check.type.js"; } from "@/domain/checks/check.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js"; import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import { CheckModel, type CheckDocument } from "@/domain/checks/check.model.js"; import { CheckModel, type CheckDocument } from "@/domain/checks/check.model.js";
import mongoose from "mongoose"; import mongoose from "mongoose";
import { getDateFormat, getDateForRange } from "@/utils/dataUtils.js"; import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { getHardwareUpChecks, getHardwareStats, getHardwareTotalChecks } from "@/domain/checks/check.hardware.aggregations.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"; const SERVICE_NAME = "StatusService";
@@ -184,12 +180,7 @@ class MongoChecksRepository implements IChecksRepository {
// Map id to _id for MongoDB storage // Map id to _id for MongoDB storage
const { id, metadata, ...rest } = check; const { id, metadata, ...rest } = check;
if (!metadata || !metadata.monitorId || !metadata.teamId) { if (!metadata || !metadata.monitorId || !metadata.teamId) {
throw new AppError({ throw new Error(`Check must have valid metadata with monitorId and teamId. Got: ${JSON.stringify({ id, metadata })}`);
message: `Check must have valid metadata with monitorId and teamId. Got: ${JSON.stringify({ id, metadata })}`,
status: 500,
service: SERVICE_NAME,
method: "toDocument",
});
} }
return { return {
_id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(), _id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(),
@@ -213,41 +204,50 @@ class MongoChecksRepository implements IChecksRepository {
return this.mapDocuments(inserted as unknown as CheckDocument[]); 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 ( findByMonitorId = async (
monitorId: string, monitorId: string,
sortOrder: string, sortOrder: string,
dateRange: DateRange, dateRange: string,
filter: string | undefined,
page: number, page: number,
rowsPerPage: number, rowsPerPage: number,
status: boolean | undefined, status: boolean | undefined
filter?: CheckFilter
) => { ) => {
// Match // Match
const matchStage: Record<string, unknown> = { const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId), "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(typeof status !== "undefined" && { status }), ...(typeof status !== "undefined" && { status }),
createdAt: { $gte: getDateForRange(dateRange) }, ...(getDateForRange(dateRange) && {
...this.filterToMatch(filter), createdAt: {
$gte: getDateForRange(dateRange),
},
}),
}; };
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 //Sort
const convertedSortOrder = sortOrder === "asc" ? 1 : -1; const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
@@ -265,12 +265,39 @@ class MongoChecksRepository implements IChecksRepository {
return { checksCount, checks: this.mapDocuments(checks) }; return { checksCount, checks: this.mapDocuments(checks) };
}; };
findByTeamId = async (sortOrder: string, dateRange: DateRange, page: number, rowsPerPage: number, teamId: string, filter?: CheckFilter) => { findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
const matchStage: Record<string, unknown> = { const matchStage: Record<string, unknown> = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId), "metadata.teamId": new mongoose.Types.ObjectId(teamId),
createdAt: { $gte: getDateForRange(dateRange) }, ...(getDateForRange(dateRange) && {
...this.filterToMatch(filter), createdAt: {
$gte: getDateForRange(dateRange),
},
}),
}; };
// 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; const parsedSortOrder = sortOrder === "asc" ? 1 : -1;
@@ -314,25 +341,25 @@ class MongoChecksRepository implements IChecksRepository {
return mapped; return mapped;
}; };
findByDateRangeAndMonitorId = async (monitorId: string, dateRange: DateRange, options?: { type?: MonitorType }) => { findByDateRangeAndMonitorId = async (monitorId: string, startDate: Date, endDate: Date, dateString: string, options?: { type?: MonitorType }) => {
const monitorObjectId = new mongoose.Types.ObjectId(monitorId); const monitorObjectId = new mongoose.Types.ObjectId(monitorId);
const start = getDateForRange(dateRange);
const dateString = getDateFormat(dateRange);
const end = new Date();
if (options?.type === "hardware") { if (options?.type === "hardware") {
return this.findHardwareDateRangeChecks(monitorObjectId, start, end, dateString); return this.findHardwareDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
} }
if (options?.type === "pagespeed") { if (options?.type === "pagespeed") {
return this.findPageSpeedDateRangeChecks(monitorObjectId, start, end, dateString); return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
} }
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString); return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, startDate, endDate, dateString);
}; };
findSummaryByTeamId = async (teamId: string, dateRange: DateRange) => { findSummaryByTeamId = async (teamId: string, dateRange: string) => {
const baseMatch = { const baseMatch = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId), "metadata.teamId": new mongoose.Types.ObjectId(teamId),
createdAt: { $gte: getDateForRange(dateRange) }, ...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
}; };
const [totalResult, downResult] = await Promise.all([ const [totalResult, downResult] = await Promise.all([
@@ -445,30 +472,11 @@ class MongoChecksRepository implements IChecksRepository {
$dateToString: { format: dateString, date: "$createdAt" }, $dateToString: { format: dateString, date: "$createdAt" },
}, },
avgResponseTime: { $avg: "$responseTime" }, avgResponseTime: { $avg: "$responseTime" },
avgDns: { $avg: { $ifNull: ["$timings.phases.dns", 0] } },
avgTcp: { $avg: { $ifNull: ["$timings.phases.tcp", 0] } },
avgTls: { $avg: { $ifNull: ["$timings.phases.tls", 0] } },
avgRequest: { $avg: { $ifNull: ["$timings.phases.request", 0] } },
avgFirstByte: { $avg: { $ifNull: ["$timings.phases.firstByte", 0] } },
avgDownload: { $avg: { $ifNull: ["$timings.phases.download", 0] } },
totalChecks: { $sum: 1 }, totalChecks: { $sum: 1 },
}, },
}, },
{ $sort: { _id: 1 } }, { $sort: { _id: 1 } },
{ { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
$project: {
bucketDate: "$_id",
avgResponseTime: 1,
totalChecks: 1,
avgDns: 1,
avgTcp: 1,
avgTls: 1,
avgRequest: 1,
avgFirstByte: 1,
avgDownload: 1,
_id: 0,
},
},
], ],
groupedUpChecks: [ groupedUpChecks: [
{ $match: { status: true } }, { $match: { status: true } },
@@ -532,13 +540,12 @@ class MongoChecksRepository implements IChecksRepository {
totalChecks: upChecksDoc?.totalChecks ?? 0, totalChecks: upChecksDoc?.totalChecks ?? 0,
}; };
const checks = (hardwareMetrics ?? []).map( const checks = (hardwareMetrics ?? []).map((metric) => ({
(metric): HardwareCheckStats => ({
bucketDate: metric._id, bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0, avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0, avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [], avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk) => ({ disks: (metric.disks ?? []).map((disk: { [key: string]: number | string | undefined }) => ({
name: disk?.name ?? "", name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0, readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0, writeSpeed: disk?.writeSpeed ?? 0,
@@ -546,7 +553,7 @@ class MongoChecksRepository implements IChecksRepository {
freeBytes: disk?.freeBytes ?? 0, freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0, usagePercent: disk?.usagePercent ?? 0,
})), })),
net: (metric.net ?? []).map((iface) => ({ net: (metric.net ?? []).map((iface: { [key: string]: number | string | undefined }) => ({
name: iface?.name ?? "", name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0, bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0, deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
@@ -559,8 +566,7 @@ class MongoChecksRepository implements IChecksRepository {
deltaFifoIn: iface?.deltaFifoIn ?? 0, deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0, deltaFifoOut: iface?.deltaFifoOut ?? 0,
})), })),
}) }));
);
return { return {
monitorType: "hardware" as const, monitorType: "hardware" as const,
+17 -14
View File
@@ -6,7 +6,6 @@ import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.j
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js"; import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import { CheckFilter, DateRange } from "@/types/query.js";
const SERVICE_NAME = "checkService"; const SERVICE_NAME = "checkService";
@@ -19,21 +18,21 @@ export interface ICheckService {
monitorId: string; monitorId: string;
teamId: string; teamId: string;
sortOrder: string; sortOrder: string;
dateRange: DateRange; dateRange: string;
page: number; page: number;
rowsPerPage: number; rowsPerPage: number;
filter?: CheckFilter; filter?: string;
status?: boolean; status?: boolean;
}): Promise<ChecksQueryResult>; }): Promise<ChecksQueryResult>;
getChecksByTeam(params: { getChecksByTeam(params: {
teamId: string; teamId: string;
sortOrder: string; sortOrder: string;
dateRange: DateRange; dateRange: string;
page: number; page: number;
rowsPerPage: number; rowsPerPage: number;
filter?: CheckFilter; filter?: string;
}): Promise<ChecksQueryResult>; }): Promise<ChecksQueryResult>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: DateRange }): Promise<ChecksSummary>; getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>; deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
deleteChecksByTeamId(params: { teamId: string }): Promise<number>; deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
deleteOlderThan(date: Date): Promise<number>; deleteOlderThan(date: Date): Promise<number>;
@@ -51,6 +50,10 @@ export class CheckService implements ICheckService {
this.checksRepository = checksRepository; this.checksRepository = checksRepository;
} }
get serviceName() {
return CheckService.SERVICE_NAME;
}
createChecks = async (checks: Check[]) => { createChecks = async (checks: Check[]) => {
return this.checksRepository.createChecks(checks); return this.checksRepository.createChecks(checks);
}; };
@@ -165,11 +168,11 @@ export class CheckService implements ICheckService {
monitorId: string; monitorId: string;
teamId: string; teamId: string;
sortOrder: string; sortOrder: string;
dateRange: DateRange; dateRange: string;
page: number; page: number;
rowsPerPage: number; rowsPerPage: number;
status?: boolean; status?: boolean;
filter: CheckFilter; filter?: string;
}) => { }) => {
if (!monitorId) { if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 }); throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
@@ -184,7 +187,7 @@ export class CheckService implements ICheckService {
const parsedPage = page ?? 0; const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5; const parsedRowsPerPage = rowsPerPage ?? 5;
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, status, filter); const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, status);
return result; return result;
}; };
@@ -193,25 +196,25 @@ export class CheckService implements ICheckService {
teamId, teamId,
sortOrder, sortOrder,
dateRange, dateRange,
filter,
page, page,
rowsPerPage, rowsPerPage,
filter,
}: { }: {
teamId: string; teamId: string;
sortOrder: string; sortOrder: string;
dateRange: DateRange; dateRange: string;
page: number; page: number;
rowsPerPage: number; rowsPerPage: number;
filter?: CheckFilter; filter?: string;
}) => { }) => {
const parsedPage = page ?? 0; const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5; const parsedRowsPerPage = rowsPerPage ?? 5;
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, parsedPage, parsedRowsPerPage, teamId, filter); const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId);
return checkData; return checkData;
}; };
getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: DateRange }) => { getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: string }) => {
const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange); const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange);
return summary; return summary;
}; };
+47 -57
View File
@@ -1,4 +1,4 @@
import type { MonitorType } from "@/domain/monitors/monitor.type.js"; import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { Response } from "got"; import type { Response } from "got";
export const CHECK_TTL_SENTINEL = 366; export const CHECK_TTL_SENTINEL = 366;
@@ -126,21 +126,49 @@ export interface PageSpeedChecksResult {
groupedChecks: PageSpeedGroupedCheck[]; 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 { export interface GroupedCheck {
bucketDate: string; bucketDate: string;
avgResponseTime: number; avgResponseTime: number;
totalChecks: number; totalChecks: number;
} }
export interface GroupedUptimeCheck extends GroupedCheck {
avgDns: number;
avgTcp: number;
avgTls: number;
avgRequest: number;
avgFirstByte: number;
avgDownload: number;
}
export interface PageSpeedGroupedCheck { export interface PageSpeedGroupedCheck {
bucketDate: string; bucketDate: string;
performance: number; performance: number;
@@ -152,7 +180,7 @@ export interface PageSpeedGroupedCheck {
export interface UptimeChecksResult { export interface UptimeChecksResult {
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">; monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
groupedChecks: GroupedUptimeCheck[]; groupedChecks: GroupedCheck[];
groupedUpChecks: GroupedCheck[]; groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[];
uptimePercentage: number; uptimePercentage: number;
@@ -168,50 +196,12 @@ export interface HasResponseTime {
responseTime: number; responseTime: number;
} }
export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
originalResponseTime: number;
};
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
export type CheckSnapshot = Omit<Check, "metadata" | "updatedAt">; export type CheckSnapshot = Omit<Check, "metadata" | "updatedAt">;
export interface HardwareDiskStats {
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,6 +79,10 @@ export class DiagnosticService implements IDiagnosticService {
obs.observe({ entryTypes: ["measure"] }); obs.observe({ entryTypes: ["measure"] });
} }
get serviceName() {
return DiagnosticService.SERVICE_NAME;
}
private getCPUUsagePercentage = async (): Promise<number> => { private getCPUUsagePercentage = async (): Promise<number> => {
const startUsage = process.cpuUsage(); const startUsage = process.cpuUsage();
const timingPeriod = 1000; // measured in ms const timingPeriod = 1000; // measured in ms
@@ -1,5 +1,5 @@
import { Schema, model, Types } from "mongoose"; import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js"; import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/domain/geo-checks/geo-check.type.js"; import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/domain/geo-checks/geo-check.type.js";
type GeoCheckMetadataDocument = Omit<GeoCheckMetadata, "monitorId" | "teamId"> & { type GeoCheckMetadataDocument = Omit<GeoCheckMetadata, "monitorId" | "teamId"> & {
@@ -1,6 +1,5 @@
import type { GeoCheck, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js"; 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 type { GeoContinent, FlatGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import { DateRange } from "@/types/query.js";
export interface GeoChecksQueryResult { export interface GeoChecksQueryResult {
geoChecksCount: number; geoChecksCount: number;
@@ -17,12 +16,19 @@ export interface IGeoChecksRepository {
findByMonitorId( findByMonitorId(
monitorId: string, monitorId: string,
sortOrder: string, sortOrder: string,
dateRange: DateRange, dateRange: string,
page: number, page: number,
rowsPerPage: number, rowsPerPage: number,
continents?: GeoContinent[] continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult>; ): Promise<FlatGeoChecksQueryResult>;
findGroupedByMonitorIdAndDateRange(monitorId: string, DateRange: DateRange, continents?: GeoContinent[]): Promise<GroupedGeoCheck[]>; findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]>;
findGroupedByMonitorIdAndDateRange(
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]>;
deleteByMonitorId(monitorId: string): Promise<number>; deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>; deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>; deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
@@ -3,15 +3,20 @@ import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoCo
import type { FlatGeoChecksQueryResult } from "./geo-check.repository.interface.js"; import type { FlatGeoChecksQueryResult } from "./geo-check.repository.interface.js";
import { GeoCheckMetadataDocument, GeoCheckModel, type GeoCheckDocument } from "@/domain/geo-checks/geo-check.model.js"; import { GeoCheckMetadataDocument, GeoCheckModel, type GeoCheckDocument } from "@/domain/geo-checks/geo-check.model.js";
import mongoose, { PipelineStage } from "mongoose"; import mongoose, { PipelineStage } from "mongoose";
import { getDateForRange, getDateFormat } from "@/utils/dataUtils.js"; import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "GeoChecksRepository"; const SERVICE_NAME = "GeoChecksRepository";
class MongoGeoChecksRepository implements IGeoChecksRepository { class MongoGeoChecksRepository implements IGeoChecksRepository {
static SERVICE_NAME = SERVICE_NAME; static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
private toEntity = (doc: GeoCheckDocument): GeoCheck => { private toEntity = (doc: GeoCheckDocument): GeoCheck => {
const mapMetadata = (metadata: GeoCheckMetadataDocument): GeoCheckMetadata => ({ const mapMetadata = (metadata: GeoCheckMetadataDocument): GeoCheckMetadata => ({
monitorId: toStringId(metadata.monitorId), monitorId: toStringId(metadata.monitorId),
@@ -58,6 +63,7 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
}; };
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => { createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
try {
const docs = await GeoCheckModel.insertMany( const docs = await GeoCheckModel.insertMany(
geoChecks.map((geoCheck) => ({ geoChecks.map((geoCheck) => ({
metadata: { metadata: {
@@ -70,19 +76,33 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
})) }))
); );
return docs.map((doc) => this.toEntity(doc)); 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;
}
}; };
findByMonitorId = async ( findByMonitorId = async (
monitorId: string, monitorId: string,
sortOrder: string, sortOrder: string,
dateRange: DateRange, dateRange: string,
page: number, page: number,
rowsPerPage: number, rowsPerPage: number,
continents?: GeoContinent[] continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult> => { ): Promise<FlatGeoChecksQueryResult> => {
try {
const matchStage: Record<string, unknown> = { const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId), "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: { $gte: getDateForRange(dateRange) }, ...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
}; };
const convertedSortOrder = sortOrder === "asc" ? 1 : -1; const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
@@ -159,21 +179,57 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
})); }));
return { geoChecksCount, geoChecks }; 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;
}
}; };
findGroupedByMonitorIdAndDateRange = async (monitorId: string, dateRange: DateRange, continents?: GeoContinent[]): Promise<GroupedGeoCheck[]> => { findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
const start = getDateForRange(dateRange); try {
const dateString = getDateFormat(dateRange); const docs = await GeoCheckModel.find({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: startDate,
$lte: endDate,
},
}).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;
}
};
const end = new Date(); findGroupedByMonitorIdAndDateRange = async (
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]> => {
try {
const pipeline: PipelineStage[] = [ const pipeline: PipelineStage[] = [
// Match geo checks for this monitor in date range // Match geo checks for this monitor in date range
{ {
$match: { $match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId), "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: { createdAt: {
$gte: start, $gte: startDate,
$lte: end, $lte: endDate,
}, },
}, },
}, },
@@ -197,7 +253,7 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
_id: { _id: {
bucketDate: { bucketDate: {
$dateToString: { $dateToString: {
format: dateString, format: dateFormat,
date: "$createdAt", date: "$createdAt",
}, },
}, },
@@ -246,26 +302,65 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
const results = await GeoCheckModel.aggregate(pipeline); const results = await GeoCheckModel.aggregate(pipeline);
return results as GroupedGeoCheck[]; 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;
}
}; };
deleteByMonitorId = async (monitorId: string): Promise<number> => { deleteByMonitorId = async (monitorId: string): Promise<number> => {
try {
const result = await GeoCheckModel.deleteMany({ const result = await GeoCheckModel.deleteMany({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId), "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
}); });
return result.deletedCount || 0; 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;
}
}; };
deleteByTeamId = async (teamId: string): Promise<number> => { deleteByTeamId = async (teamId: string): Promise<number> => {
try {
const result = await GeoCheckModel.deleteMany({ const result = await GeoCheckModel.deleteMany({
"metadata.teamId": new mongoose.Types.ObjectId(teamId), "metadata.teamId": new mongoose.Types.ObjectId(teamId),
}); });
return result.deletedCount || 0; 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;
}
}; };
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => { deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
try {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } }); const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
return result.deletedCount || 0; 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;
}
}; };
} }
@@ -1,5 +1,5 @@
import type { GeoCheck } from "@/domain/geo-checks/geo-check.type.js"; import type { GeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import type { Monitor } from "@/domain/monitors/monitor.type.js"; import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { GeoCheckResult, GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import type { GeoCheckResult, GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import { Types } from "mongoose"; import { Types } from "mongoose";
import type { FlatGeoChecksQueryResult, IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; 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 { IGlobalPingService } from "@/service/globalPingService.js";
import type { ILogger } from "@/utils/logger.js"; import type { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "GeoChecksService"; const SERVICE_NAME = "GeoChecksService";
export interface IGeoChecksService { export interface IGeoChecksService {
readonly serviceName: string;
buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null>; buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null>;
createGeoChecks(geoChecks: GeoCheck[]): Promise<GeoCheck[]>; createGeoChecks(geoChecks: GeoCheck[]): Promise<GeoCheck[]>;
getGeoChecksByMonitor(args: { getGeoChecksByMonitor(args: {
monitorId: string; monitorId: string;
teamId: string; teamId: string;
sortOrder: string; sortOrder: string;
dateRange: DateRange; dateRange: string;
page?: number; page?: number;
rowsPerPage?: number; rowsPerPage?: number;
continent?: GeoContinent | GeoContinent[]; continent?: GeoContinent | GeoContinent[];
@@ -50,6 +50,10 @@ export class GeoChecksService implements IGeoChecksService {
this.monitorsRepository = monitorsRepository; this.monitorsRepository = monitorsRepository;
} }
get serviceName() {
return GeoChecksService.SERVICE_NAME;
}
async buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null> { async buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null> {
try { try {
if (!monitor.url) { if (!monitor.url) {
@@ -156,7 +160,7 @@ export class GeoChecksService implements IGeoChecksService {
monitorId: string; monitorId: string;
teamId: string; teamId: string;
sortOrder: string; sortOrder: string;
dateRange: DateRange; dateRange: string;
page?: number; page?: number;
rowsPerPage?: number; rowsPerPage?: number;
continent: GeoContinent | GeoContinent[]; continent: GeoContinent | GeoContinent[];
@@ -1,4 +1,4 @@
import type { MonitorType } from "@/domain/monitors/monitor.type.js"; import type { MonitorType } from "@/domain/monitors/monitor.types.js";
export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const; export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoContinent = (typeof GeoContinents)[number]; export type GeoContinent = (typeof GeoContinents)[number];
@@ -1,5 +1,5 @@
const SERVICE_NAME = "incidentService"; const SERVICE_NAME = "incidentService";
import type { Monitor } from "@/domain/monitors/monitor.type.js"; import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { MonitorStatusResponse } from "@/types/network.js"; import type { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { getDateForRange } from "@/utils/dataUtils.js"; import { getDateForRange } from "@/utils/dataUtils.js";
@@ -11,7 +11,6 @@ import type { User } from "@/domain/users/user.type.js";
import type { MonitorActionDecision } from "@/worker/worker.helper.js"; import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import type { ILogger } from "@/utils/logger.js"; import type { ILogger } from "@/utils/logger.js";
import { DateRange } from "@/types/query.js";
export interface IIncidentService { export interface IIncidentService {
handleIncident( handleIncident(
@@ -24,7 +23,7 @@ export interface IIncidentService {
getIncidentsByTeam( getIncidentsByTeam(
teamId: string, teamId: string,
sortOrder: string, sortOrder: string,
dateRange: DateRange, dateRange: string,
page: number, page: number,
rowsPerPage: number, rowsPerPage: number,
status: boolean | undefined, status: boolean | undefined,
@@ -58,6 +57,10 @@ export class IncidentService implements IIncidentService {
this.notificationMessageBuilder = notificationMessageBuilder; this.notificationMessageBuilder = notificationMessageBuilder;
} }
get serviceName() {
return IncidentService.SERVICE_NAME;
}
handleIncident = async ( handleIncident = async (
monitor: Monitor, monitor: Monitor,
code: number, code: number,
@@ -175,7 +178,7 @@ export class IncidentService implements IIncidentService {
getIncidentsByTeam = async ( getIncidentsByTeam = async (
teamId: string, teamId: string,
sortOrder: string, sortOrder: string,
dateRange: DateRange, dateRange: string,
page: number, page: number,
rowsPerPage: number, rowsPerPage: number,
status: boolean | undefined, status: boolean | undefined,
+2 -2
View File
@@ -1,7 +1,7 @@
// export type IncidentResolutionType = "automatic" | "manual" | null; // export type IncidentResolutionType = "automatic" | "manual" | null;
export const IncidentResolutionTypes = ["automatic", "manual"] as const; export const IncidentResolutionTypes = ["automatic", "manual", null] as const;
export type IncidentResolutionType = (typeof IncidentResolutionTypes)[number] | null; export type IncidentResolutionType = (typeof IncidentResolutionTypes)[number];
export interface Incident { export interface Incident {
id: string; id: string;
@@ -35,6 +35,10 @@ export class InviteService implements IInviteService {
this.emailService = emailService; this.emailService = emailService;
} }
get serviceName() {
return InviteService.SERVICE_NAME;
}
getInviteToken = async ({ invite, teamId, userRoles }: { invite: Partial<Invite>; teamId: string; userRoles: UserRole[] }) => { getInviteToken = async ({ invite, teamId, userRoles }: { invite: Partial<Invite>; teamId: string; userRoles: UserRole[] }) => {
invite.teamId = teamId; invite.teamId = teamId;
@@ -62,6 +62,10 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
this.scheduler = scheduler; this.scheduler = scheduler;
} }
get serviceName() {
return MaintenanceWindowService.SERVICE_NAME;
}
private flipMonitorsLeavingMaintenance = async (monitorIds: string[], teamId: string, excludeWindowId: string, now: Date): Promise<void> => { private flipMonitorsLeavingMaintenance = async (monitorIds: string[], teamId: string, excludeWindowId: string, now: Date): Promise<void> => {
if (monitorIds.length === 0) return; if (monitorIds.length === 0) return;
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose"; import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.types.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js"; import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.types.js";
import type { import type {
CheckAudits, CheckAudits,
CheckCaptureInfo, CheckCaptureInfo,
@@ -1,4 +1,4 @@
import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.types.js";
export interface TeamQueryConfig { export interface TeamQueryConfig {
limit?: number; limit?: number;
@@ -26,9 +26,9 @@ export interface IMonitorsRepository {
findByIdLean(monitorId: string): Promise<Monitor | null>; findByIdLean(monitorId: string): Promise<Monitor | null>;
// collection fetch // collection fetch
findAll(): Promise<Monitor[]>; findAll(): Promise<Monitor[] | null>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[]>; findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise<Monitor[]>; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findByIds(monitorIds: string[]): Promise<Monitor[]>; findByIds(monitorIds: string[]): Promise<Monitor[]>;
findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise<Monitor[]>; findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise<Monitor[]>;
@@ -1,7 +1,7 @@
import { MonitorModel } from "@/domain/monitors/monitor.model.js"; import { MonitorModel } from "@/domain/monitors/monitor.model.js";
import type { MonitorDocument, CheckSnapshotDocument } 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 { CheckSnapshot } from "@/domain/checks/check.type.js";
import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js"; import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.types.js";
import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import mongoose, { type FilterQuery, type PipelineStage } from "mongoose";
import { MongoBulkWriteError } from "mongodb"; import { MongoBulkWriteError } from "mongodb";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
@@ -91,26 +91,27 @@ class MongoMonitorsRepository implements IMonitorsRepository {
{ $project: { stats: 0 } }, { $project: { stats: 0 } },
]; ];
private pageOptions = (config: TeamQueryConfig) => { findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {}; 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 query = this.queryBuilder(config, teamId);
const { sort, skip, limit } = this.pageOptions(config); const sort = { [field]: order === "asc" ? 1 : -1 } as const;
const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(limit); const skip = Math.max(page, 0) * rowsPerPage;
const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(rowsPerPage);
return this.mapDocuments(documents); return this.mapDocuments(documents);
}; };
findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[]> => { findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
const { sort, skip, limit } = this.pageOptions(config); const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
const query = this.queryBuilder(config, teamId); 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[] = [ const pipeline: PipelineStage[] = [
{ $match: query }, { $match: query },
{ $sort: sort }, { $sort: sort },
...(limit ? [{ $skip: skip }, { $limit: limit }] : []), ...(rowsPerPage ? [{ $skip: skip }, { $limit: rowsPerPage }] : []),
...this.uptimeStatsLookupStages, ...this.uptimeStatsLookupStages,
]; ];
+84 -38
View File
@@ -1,4 +1,5 @@
import { type Monitor } from "@/domain/monitors/monitor.type.js"; import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js";
import { type Monitor } from "@/domain/monitors/monitor.types.js";
import type { import type {
MonitorType, MonitorType,
MonitorsWithChecksByTeamIdResult, MonitorsWithChecksByTeamIdResult,
@@ -7,8 +8,8 @@ import type {
PageSpeedDetailsResult, PageSpeedDetailsResult,
GamesMap, GamesMap,
GroupedGeoCheckResult, GroupedGeoCheckResult,
} from "@/domain/monitors/monitor.type.js"; } from "@/domain/monitors/monitor.types.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.types.js";
import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/domain/checks/check.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 { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
@@ -22,27 +23,29 @@ import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/api/validation/monitorValidation.js"; import type { ImportedMonitor } from "@/api/validation/monitorValidation.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js"; import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "MonitorService"; const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult => const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
supportsUptimeDetails(result.monitorType); supportsUptimeDetails(result.monitorType);
export interface IMonitorService { export interface IMonitorService {
readonly serviceName: string;
// create // create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>; createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>; createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>; addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
// read // read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<UptimeDetailsResult>; getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<HardwareDetailsResult>; getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<PageSpeedDetailsResult>; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<PageSpeedDetailsResult>;
getGeoChecksByMonitorId(args: { getGeoChecksByMonitorId(args: {
teamId: string; teamId: string;
monitorId: string; monitorId: string;
dateRange: DateRange; dateRange: string;
continents?: GeoContinent[]; continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult>; }): Promise<GroupedGeoCheckResult>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>; getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
@@ -132,6 +135,35 @@ export class MonitorService implements IMonitorService {
this.incidentsRepository = incidentsRepository; 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> => { createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
const monitor = await this.monitorsRepository.create(body, teamId, userId); const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) { if (!monitor) {
@@ -174,13 +206,15 @@ export class MonitorService implements IMonitorService {
}: { }: {
teamId: string; teamId: string;
monitorId: string; monitorId: string;
dateRange: DateRange; dateRange: string;
}): Promise<UptimeDetailsResult> => { }): Promise<UptimeDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId); const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) { if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 }); throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
} }
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, { const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type, type: monitor.type,
}); });
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id); const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
@@ -192,9 +226,9 @@ export class MonitorService implements IMonitorService {
return { return {
monitorData: { monitorData: {
monitor, monitor,
groupedChecks: checksData.groupedChecks, groupedChecks: NormalizeDataUptimeDetails(checksData.groupedChecks, 10, 100),
groupedUpChecks: checksData.groupedUpChecks, groupedUpChecks: NormalizeDataUptimeDetails(checksData.groupedUpChecks, 10, 100),
groupedDownChecks: checksData.groupedDownChecks, groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100),
groupedAvgResponseTime: checksData.avgResponseTime, groupedAvgResponseTime: checksData.avgResponseTime,
groupedUptimePercentage: checksData.uptimePercentage, groupedUptimePercentage: checksData.uptimePercentage,
}, },
@@ -209,7 +243,7 @@ export class MonitorService implements IMonitorService {
}: { }: {
teamId: string; teamId: string;
monitorId: string; monitorId: string;
dateRange: DateRange; dateRange: string;
}): Promise<HardwareDetailsResult> => { }): Promise<HardwareDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId); const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) { if (!monitor) {
@@ -219,7 +253,9 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 }); throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
} }
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, { const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type, type: monitor.type,
}); });
@@ -249,7 +285,7 @@ export class MonitorService implements IMonitorService {
}: { }: {
teamId: string; teamId: string;
monitorId: string; monitorId: string;
dateRange: DateRange; dateRange: string;
}): Promise<PageSpeedDetailsResult> => { }): Promise<PageSpeedDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId); const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) { if (!monitor) {
@@ -259,7 +295,9 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 }); throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
} }
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, { const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type, type: monitor.type,
}); });
@@ -286,7 +324,7 @@ export class MonitorService implements IMonitorService {
}: { }: {
teamId: string; teamId: string;
monitorId: string; monitorId: string;
dateRange: DateRange; dateRange: string;
continents?: GeoContinent[]; continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult> => { }): Promise<GroupedGeoCheckResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId); const monitor = await this.monitorsRepository.findById(monitorId, teamId);
@@ -298,7 +336,15 @@ export class MonitorService implements IMonitorService {
return { groupedGeoChecks: [] }; return { groupedGeoChecks: [] };
} }
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(monitor.id, dateRange, continents); 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
);
return { groupedGeoChecks }; return { groupedGeoChecks };
}; };
@@ -317,7 +363,7 @@ export class MonitorService implements IMonitorService {
type?: MonitorType | MonitorType[]; type?: MonitorType | MonitorType[];
tags?: string | string[]; tags?: string | string[];
filter?: string; filter?: string;
}): Promise<Monitor[]> => { }): Promise<Monitor[] | null> => {
return await this.monitorsRepository.findByTeamId(teamId, { type, tags, filter }); return await this.monitorsRepository.findByTeamId(teamId, { type, tags, filter });
}; };
@@ -355,15 +401,16 @@ export class MonitorService implements IMonitorService {
order, order,
}); });
const monitorsList = (monitors ?? []) as Monitor[];
const snapshotTypes: MonitorType[] = ["hardware"]; const snapshotTypes: MonitorType[] = ["hardware"];
const requestedTypes = Array.isArray(type) ? type : type ? [type] : []; const requestedTypes = Array.isArray(type) ? type : type ? [type] : [];
const snapshotOnlyRequest = const snapshotOnlyRequest =
requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType)); requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType));
const monitorsWithChecks = monitors.map((monitor: Monitor) => { const monitorsWithChecks = monitorsList.map((monitor: Monitor) => {
const rawChecks = monitor.recentChecks ?? []; const rawChecks = monitor.recentChecks ?? [];
const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type); const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type);
const checks = isSnapshotType ? rawChecks.slice(0, 1) : rawChecks; const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100);
monitor.recentChecks = checks; monitor.recentChecks = checks;
return monitor; return monitor;
}); });
@@ -449,7 +496,15 @@ export class MonitorService implements IMonitorService {
return { monitors, failedCount }; return { monitors, failedCount };
}; };
private deleteMonitorChildren = async (monitor: Monitor, teamId: string) => { 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,
});
});
await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => { await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({ this.logger.warn({
message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`, message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`,
@@ -481,19 +536,6 @@ 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); await this.scheduler.deleteJob(monitor);
return monitor; return monitor;
}; };
@@ -503,8 +545,12 @@ export class MonitorService implements IMonitorService {
await Promise.all( await Promise.all(
monitors.map(async (monitor) => { monitors.map(async (monitor) => {
try { try {
await this.deleteMonitorChildren(monitor, teamId);
await this.scheduler.deleteJob(monitor); 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) { } catch (error: unknown) {
this.logger.warn({ this.logger.warn({
message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`, message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`,
@@ -521,7 +567,7 @@ export class MonitorService implements IMonitorService {
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<Monitor[]> => { exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<Monitor[]> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (monitors.length === 0) { if (!monitors || monitors.length === 0) {
throw new AppError({ message: "No monitors found to export.", service: SERVICE_NAME, method: "exportMonitorsToJSON", status: 400 }); throw new AppError({ message: "No monitors found to export.", service: SERVICE_NAME, method: "exportMonitorsToJSON", status: 400 });
} }
@@ -3,7 +3,6 @@ export type { CheckSnapshot } from "@/domain/checks/check.type.js";
import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-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"; export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import http from "node:http"; import http from "node:http";
import { HardwareStats } from "@/domain/checks/check.type.js";
export const HttpStatusCodes = [ export const HttpStatusCodes = [
...Object.keys(http.STATUS_CODES).map(Number), ...Object.keys(http.STATUS_CODES).map(Number),
@@ -150,6 +149,48 @@ export interface UptimeDetailsResult {
monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null; 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 { export interface HardwareDetailsResult {
monitor: Monitor; monitor: Monitor;
stats: HardwareStats; stats: HardwareStats;
@@ -1,4 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js"; import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import type { MonitorActionDecision } from "@/worker/worker.helper.js"; import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type { import type {
@@ -1,4 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js"; import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { Notification } from "@/domain/notifications/notification.type.js"; import type { Notification } from "@/domain/notifications/notification.type.js";
import type { MonitorStatusResponse } from "@/types/network.js"; import type { MonitorStatusResponse } from "@/types/network.js";
import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
@@ -9,9 +9,6 @@ import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.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 { export interface INotificationsService {
createNotification: (notificationData: Partial<Notification>, userId: string, teamId: string) => Promise<Notification>; createNotification: (notificationData: Partial<Notification>, userId: string, teamId: string) => Promise<Notification>;
@@ -32,29 +29,52 @@ export class NotificationsService implements INotificationsService {
private notificationsRepository: INotificationsRepository; private notificationsRepository: INotificationsRepository;
private monitorsRepository: IMonitorsRepository; private monitorsRepository: IMonitorsRepository;
private providers: NotificationProviderRegistry; private webhookProvider: INotificationProvider;
private settingsService: ISettingsService; 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 logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder; private notificationMessageBuilder: INotificationMessageBuilder;
constructor({ constructor(
notificationsRepository, notificationsRepository: INotificationsRepository,
monitorsRepository, monitorsRepository: IMonitorsRepository,
providers, webhookProvider: INotificationProvider,
settingsService, emailProvider: INotificationProvider,
logger, slackProvider: INotificationProvider,
notificationMessageBuilder, discordProvider: INotificationProvider,
}: { pagerDutyProvider: INotificationProvider,
notificationsRepository: INotificationsRepository; matrixProvider: INotificationProvider,
monitorsRepository: IMonitorsRepository; teamsProvider: INotificationProvider,
providers: NotificationProviderRegistry; telegramProvider: INotificationProvider,
settingsService: ISettingsService; pushoverProvider: INotificationProvider,
logger: ILogger; twilioProvider: INotificationProvider,
notificationMessageBuilder: INotificationMessageBuilder; ntfyProvider: INotificationProvider,
}) { settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
) {
this.notificationsRepository = notificationsRepository; this.notificationsRepository = notificationsRepository;
this.monitorsRepository = monitorsRepository; this.monitorsRepository = monitorsRepository;
this.providers = providers; 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.settingsService = settingsService; this.settingsService = settingsService;
this.logger = logger; this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder; this.notificationMessageBuilder = notificationMessageBuilder;
@@ -77,8 +97,30 @@ export class NotificationsService implements INotificationsService {
} }
// Route to provider based on notification type // Route to provider based on notification type
const provider = this.providers[notification.type]; switch (notification.type) {
if (!provider) { 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({ this.logger.warn({
message: `Unknown notification type: ${notification.type}`, message: `Unknown notification type: ${notification.type}`,
service: SERVICE_NAME, service: SERVICE_NAME,
@@ -86,7 +128,6 @@ export class NotificationsService implements INotificationsService {
}); });
return false; return false;
} }
return await provider.sendMessage(notification, notificationMessage);
}; };
private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => {
@@ -124,26 +165,32 @@ export class NotificationsService implements INotificationsService {
}; };
sendTestNotification = async (notification: Partial<Notification>) => { sendTestNotification = async (notification: Partial<Notification>) => {
const type = notification.type; switch (notification.type) {
if (!type) { case "email":
this.logger.warn({ return await this.emailProvider.sendTestAlert(notification);
message: "Notification type not provided", case "slack":
service: SERVICE_NAME, return await this.slackProvider.sendTestAlert(notification);
method: "sendTestNotification", 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; 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[]) => { testAllNotifications = async (notificationIds: string[]) => {
@@ -4,8 +4,7 @@ import { QueueWorkerDocument } from "@/domain/queue-workers/queue-worker.type.js
// Single source of truth for worker liveness: the TTL that GCs stale records and // 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. // the window readers use to count a worker as alive. Heartbeat cadence must stay well below it.
const WORKER_TTL_SECONDS = 180; export const WORKER_TTL_SECONDS = 180;
export const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000;
const QueueWorkerSchema = new Schema<QueueWorkerDocument>( const QueueWorkerSchema = new Schema<QueueWorkerDocument>(
{ {
@@ -9,7 +9,8 @@ import {
} from "@/domain/status-pages/status-page.type.js"; } from "@/domain/status-pages/status-page.type.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { Monitor } from "@/domain/monitors/monitor.type.js"; import { Monitor } from "@/domain/monitors/monitor.types.js";
import { NormalizeData } from "@/utils/dataUtils.js";
export interface IStatusPageService { export interface IStatusPageService {
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>; createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
@@ -78,6 +79,7 @@ export class StatusPageService implements IStatusPageService {
status: monitor.status, status: monitor.status,
uptimePercentage: monitor.uptimePercentage, uptimePercentage: monitor.uptimePercentage,
recentChecks: monitor.recentChecks, recentChecks: monitor.recentChecks,
checks: NormalizeData(monitor.recentChecks, 10, 100),
}; };
if (showURL) { if (showURL) {
@@ -1,4 +1,5 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js"; import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { NormalizedCheck, CheckSnapshot } from "@/domain/checks/check.type.js";
export const StatusPageTypes = ["uptime", "infrastructure"] as const; export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number]; export type StatusPageType = (typeof StatusPageTypes)[number];
@@ -47,7 +48,7 @@ export interface StatusPage {
} }
export type PublicStatusPageMonitor = Pick<Monitor, "id" | "name" | "type" | "status" | "uptimePercentage" | "recentChecks"> & export type PublicStatusPageMonitor = Pick<Monitor, "id" | "name" | "type" | "status" | "uptimePercentage" | "recentChecks"> &
Partial<Pick<Monitor, "url" | "port">>; Partial<Pick<Monitor, "url" | "port">> & { checks: NormalizedCheck<CheckSnapshot>[] };
export interface PublicStatusPagePayload { export interface PublicStatusPagePayload {
statusPage: StatusPage; statusPage: StatusPage;
@@ -2,6 +2,7 @@ import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import { UserModel, type UserDocument } from "@/domain/users/user.model.js"; import { UserModel, type UserDocument } from "@/domain/users/user.model.js";
import type { User, UserProfileImage } from "@/domain/users/user.type.js"; import type { User, UserProfileImage } from "@/domain/users/user.type.js";
import { GenerateAvatarImage } from "@/utils/imageProcessing.js"; import { GenerateAvatarImage } from "@/utils/imageProcessing.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js";
const SERVICE_NAME = "MongoUsersRepository"; const SERVICE_NAME = "MongoUsersRepository";
@@ -71,7 +72,7 @@ class MongoUsersRepository implements IUsersRepository {
findById = async (id: string) => { findById = async (id: string) => {
const user = await UserModel.findById(id).select("-password").select("-profileImage"); const user = await UserModel.findById(id).select("-password").select("-profileImage");
if (!user) { if (!user) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); throw new Error("User not found");
} }
return this.toEntity(user); return this.toEntity(user);
@@ -86,8 +87,9 @@ class MongoUsersRepository implements IUsersRepository {
const candidateUser = { ...patch }; const candidateUser = { ...patch };
let unsetFields: Record<string, 1> | undefined; let unsetFields: Record<string, 1> | undefined;
if (candidateUser.deleteProfileImage === true) { if (ParseBoolean(candidateUser.deleteProfileImage) === true) {
unsetFields = { profileImage: 1, avatarImage: 1 }; unsetFields = { profileImage: 1, avatarImage: 1 };
delete candidateUser.deleteProfileImage;
} else if (file) { } else if (file) {
// 1. Save the full size image // 1. Save the full size image
candidateUser.profileImage = { candidateUser.profileImage = {
+6 -2
View File
@@ -24,7 +24,7 @@ export interface IUserService {
createUser(userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null): Promise<User>; 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 }>; loginUser(email: string, password: string): Promise<{ user: User; token: string }>;
editUser( editUser(
updates: Partial<User & { newPassword?: string; deleteProfileImage?: boolean }>, updates: Partial<User & { newPassword?: string }>,
file: Express.Multer.File | null, file: Express.Multer.File | null,
currentUserId: string, currentUserId: string,
currentUserEmail: string currentUserEmail: string
@@ -103,6 +103,10 @@ export class UserService implements IUserService {
this.teamsRepository = teamsRepository; this.teamsRepository = teamsRepository;
} }
get serviceName() {
return UserService.SERVICE_NAME;
}
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => { issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL }); return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
}; };
@@ -238,7 +242,7 @@ export class UserService implements IUserService {
}; };
editUser = async ( editUser = async (
updates: Partial<User & { newPassword?: string; deleteProfileImage?: boolean }>, updates: Partial<User & { newPassword?: string }>,
file: Express.Multer.File | null, file: Express.Multer.File | null,
currentUserId: string, currentUserId: string,
currentUserEmail: string currentUserEmail: string
+1 -1
View File
@@ -27,7 +27,7 @@ const startApp = async () => {
const env = validateEnv(logger); const env = validateEnv(logger);
logger.setLogLevel(env.LOG_LEVEL); logger.setLogLevel(env.LOG_LEVEL);
const settingsService = new SettingsService(env); const settingsService = new SettingsService(env);
const envSettings = settingsService.getSettings(); const envSettings = settingsService.loadSettings();
const { queueMode, queuePrimaryProcesses } = envSettings; const { queueMode, queuePrimaryProcesses } = envSettings;
logger.info({ logger.info({
+4
View File
@@ -51,6 +51,10 @@ export class BufferService implements IBufferService {
}); });
} }
get serviceName() {
return BufferService.SERVICE_NAME;
}
addToBuffer(check: Check) { addToBuffer(check: Check) {
try { try {
this.buffer.push(check); this.buffer.push(check);
+5
View File
@@ -56,6 +56,10 @@ export class EmailService implements IEmailService {
this.init(); this.init();
} }
get serviceName() {
return EmailService.SERVICE_NAME;
}
init = () => { init = () => {
const loadTemplate = (templateName: string) => { const loadTemplate = (templateName: string) => {
try { try {
@@ -75,6 +79,7 @@ export class EmailService implements IEmailService {
this.templateLookup = { this.templateLookup = {
welcomeEmailTemplate: loadTemplate("welcomeEmail"), welcomeEmailTemplate: loadTemplate("welcomeEmail"),
employeeActivationTemplate: loadTemplate("employeeActivation"), employeeActivationTemplate: loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: loadTemplate("passwordReset"), passwordResetTemplate: loadTemplate("passwordReset"),
testEmailTemplate: loadTemplate("testEmailTemplate"), testEmailTemplate: loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: loadTemplate("unifiedNotification"), unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
+7 -2
View File
@@ -1,6 +1,6 @@
import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/domain/geo-checks/geo-check.type.js"; import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/domain/geo-checks/geo-check.type.js";
import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor.types.js";
import { MonitorType } from "@/domain/monitors/monitor.type.js"; import { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { ILogger } from "@/utils/logger.js"; import type { ILogger } from "@/utils/logger.js";
import got from "got"; import got from "got";
import { isStatusUp } from "@/service/network/utils.js"; import { isStatusUp } from "@/service/network/utils.js";
@@ -61,6 +61,7 @@ interface GlobalPingProbeResult {
} }
export interface IGlobalPingService { export interface IGlobalPingService {
readonly serviceName: string;
createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null>; createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null>;
pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: HttpStatusCode[]): Promise<GeoCheckResult[]>; pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: HttpStatusCode[]): Promise<GeoCheckResult[]>;
} }
@@ -74,6 +75,10 @@ export class GlobalPingService implements IGlobalPingService {
this.logger = logger; this.logger = logger;
} }
get serviceName() {
return GlobalPingService.SERVICE_NAME;
}
async createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null> { async createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null> {
try { try {
if (!supportsGeoCheck(monitorType)) { if (!supportsGeoCheck(monitorType)) {
@@ -1,4 +1,4 @@
import { Monitor } from "@/domain/monitors/monitor.type.js"; import { Monitor } from "@/domain/monitors/monitor.types.js";
import jmespath from "jmespath"; import jmespath from "jmespath";
import RE2 from "re2"; import RE2 from "re2";
type JmesPath = typeof jmespath; type JmesPath = typeof jmespath;
+2 -3
View File
@@ -1,10 +1,9 @@
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import type { Resolver } from "dns/promises"; import type { Resolver } from "dns/promises";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js"; import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
const SERVICE_NAME = "DNSProvider"; const SERVICE_NAME = "DNSProvider";
+3 -3
View File
@@ -1,11 +1,11 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode"; import Dockerode from "dockerode";
import { timeRequest } from "@/service/network/utils.js"; import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
type DockerodeType = typeof Dockerode; type DockerodeType = typeof Dockerode;
const SERVICE_NAME = "DockerProvider"; const SERVICE_NAME = "DockerProvider";
+2 -2
View File
@@ -1,9 +1,9 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { GameDig } from "gamedig"; import { GameDig } from "gamedig";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js"; import { NETWORK_ERROR } from "@/service/network/utils.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
type GameDigType = typeof GameDig; type GameDigType = typeof GameDig;
+1 -1
View File
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js"; import { timeRequest } from "@/service/network/utils.js";
import * as grpc from "@grpc/grpc-js"; import * as grpc from "@grpc/grpc-js";
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { HttpProvider } from "@/service/network/HttpProvider.js"; import { HttpProvider } from "@/service/network/HttpProvider.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
+4 -5
View File
@@ -5,9 +5,8 @@ import { HttpStatusPayload } from "@/types/network.js";
import { MonitorStatusResponse } from "@/types/network.js"; import { MonitorStatusResponse } from "@/types/network.js";
import { Agent as HttpsAgent } from "https"; import { Agent as HttpsAgent } from "https";
import { Agent as HttpAgent } from "http"; import { Agent as HttpAgent } from "http";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { isStatusUp } from "@/service/network/utils.js"; import { NETWORK_ERROR, isStatusUp } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import CacheableLookup from "cacheable-lookup"; import CacheableLookup from "cacheable-lookup";
export class HttpProvider implements IStatusProvider<HttpStatusPayload> { export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
@@ -117,7 +116,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
if (error instanceof HTTPError || error instanceof RequestError) { if (error instanceof HTTPError || error instanceof RequestError) {
const statusCode = error.response?.statusCode; const statusCode = error.response?.statusCode;
const statusUp = isStatusUp(statusCode, monitor.customUpCodes); const statusUp = isStatusUp(statusCode, monitor.customUpCodes);
const responseTime = error.timings?.phases?.total ?? 0; const responseTime = error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0;
if (!statusUp) { if (!statusUp) {
return { return {
@@ -184,7 +183,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
statusCode: response.statusCode, statusCode: response.statusCode,
statusUp, statusUp,
message: response.statusMessage ?? "OK", message: response.statusMessage ?? "OK",
responseTime: response.timings.phases.total ?? 0, responseTime: response.timings.phases.firstByte ?? 0,
timings: response.timings, timings: response.timings,
}); });
} catch (error: unknown) { } catch (error: unknown) {
@@ -1,4 +1,4 @@
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { MonitorStatusResponse } from "@/types/network.js"; import { MonitorStatusResponse } from "@/types/network.js";
export interface IStatusProvider<T> { export interface IStatusProvider<T> {
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js"; import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.types.js";
import { HttpProvider } from "@/service/network/HttpProvider.js"; import { HttpProvider } from "@/service/network/HttpProvider.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
+1 -1
View File
@@ -1,6 +1,6 @@
import { PingStatusPayload } from "@/types/network.js"; import { PingStatusPayload } from "@/types/network.js";
import { IStatusProvider } from "./IStatusProvider.js"; import { IStatusProvider } from "./IStatusProvider.js";
import { MonitorType, Monitor } from "@/domain/monitors/monitor.type.js"; import { MonitorType, Monitor } from "@/domain/monitors/monitor.types.js";
import { MonitorStatusResponse } from "@/types/network.js"; import { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import ping from "ping"; import ping from "ping";
+2 -3
View File
@@ -1,9 +1,8 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js"; import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import * as net from "net"; import * as net from "net";
type NetType = typeof net; type NetType = typeof net;
@@ -1,9 +1,8 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js"; import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import type WebSocket from "ws"; import type WebSocket from "ws";
type WebSocketConstructor = typeof WebSocket; type WebSocketConstructor = typeof WebSocket;
+4 -1
View File
@@ -1,4 +1,4 @@
import type { HttpStatusCode } from "@/domain/monitors/monitor.type.js"; import type { HttpStatusCode } from "@/domain/monitors/monitor.types.js";
export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> => { export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> => {
const start = process.hrtime.bigint(); const start = process.hrtime.bigint();
@@ -12,6 +12,9 @@ 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 => { export const isStatusUp = (statusCode: number | undefined, customUpCodes: HttpStatusCode[] = []): boolean => {
if (statusCode === undefined) return false; if (statusCode === undefined) return false;
return (statusCode >= 200 && statusCode < 300) || customUpCodes.includes(statusCode); return (statusCode >= 200 && statusCode < 300) || customUpCodes.includes(statusCode);
+7 -2
View File
@@ -1,13 +1,14 @@
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import type { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import type { AxiosStatic } from "axios"; import type { AxiosStatic } from "axios";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js"; import { NETWORK_ERROR } from "@/service/network/utils.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import { IStatusProvider } from "./network/IStatusProvider.js"; import { IStatusProvider } from "./network/IStatusProvider.js";
const SERVICE_NAME = "NetworkService"; const SERVICE_NAME = "NetworkService";
export interface INetworkService { export interface INetworkService {
readonly serviceName: string;
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>; requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestWebhook( requestWebhook(
type: string, type: string,
@@ -38,6 +39,10 @@ export class NetworkService implements INetworkService {
this.logger = logger; this.logger = logger;
} }
get serviceName(): string {
return NetworkService.SERVICE_NAME;
}
// Main entry point // Main entry point
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> { async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
const provider = this.providers.find((p) => p.supports(monitor.type)); const provider = this.providers.find((p) => p.supports(monitor.type));
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more