Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0373989f | ||
|
|
ac51dfeab7 | ||
|
|
99a5aa1722 | ||
|
|
5791bdd5e7 | ||
|
|
f7514bc938 | ||
|
|
2bbaf7d1eb | ||
|
|
4222d39a8b | ||
|
|
fb69e29adf | ||
|
|
0d90083e63 | ||
|
|
8ab049aa6c | ||
|
|
69b6955ced | ||
|
|
04dfff4ebd | ||
|
|
15b0deb0bd | ||
|
|
a895912b99 | ||
|
|
22d2626a93 | ||
|
|
214fc4899c | ||
|
|
e6088fe06f | ||
|
|
60b1d95b74 | ||
|
|
94ca9896db | ||
|
|
13a7ae82e3 | ||
|
|
de3af64254 | ||
|
|
317d531775 | ||
|
|
2b2db2df6f | ||
|
|
c558c4b635 | ||
|
|
be29cce788 | ||
|
|
e87879970a | ||
|
|
4c94a7c21e | ||
|
|
40968c370b | ||
|
|
a864bc0c05 | ||
|
|
62e86434c2 | ||
|
|
07957d3a1f | ||
|
|
bc088c3589 | ||
|
|
5ea86d8e71 | ||
|
|
0944dba386 | ||
|
|
86acc0c30a | ||
|
|
dcdde845db | ||
|
|
3039fb9b69 | ||
|
|
729600a86f | ||
|
|
51853cb360 | ||
|
|
f7db732d67 | ||
|
|
9e686a2319 | ||
|
|
da6f03ba59 | ||
|
|
276b11603e | ||
|
|
f09f0630dc | ||
|
|
1defa52ae8 | ||
|
|
f43b7584a6 | ||
|
|
ee74584699 | ||
|
|
2422fcc83b | ||
|
|
0ecc2e21e5 | ||
|
|
fe242b1977 | ||
|
|
d1f1d31ea2 | ||
|
|
eacbb227c3 | ||
|
|
ec129e3604 |
No files matched your search
@@ -3,8 +3,10 @@
|
|||||||
.VSCodeCounter
|
.VSCodeCounter
|
||||||
*.sh
|
*.sh
|
||||||
mongo
|
mongo
|
||||||
|
timescaledb
|
||||||
node_modules/
|
node_modules/
|
||||||
docs/architecture
|
docs/architecture
|
||||||
docs/reviews
|
docs/reviews
|
||||||
docs/todo
|
docs/todo
|
||||||
docs/frontend
|
docs/frontend
|
||||||
|
docs/timescale
|
||||||
@@ -48,6 +48,10 @@ You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.
|
|||||||
|
|
||||||
Usage instructions can be found [here](https://checkmate.so/docs).
|
Usage instructions can be found [here](https://checkmate.so/docs).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- [Docker](https://www.docker.com/) installed
|
||||||
|
- [Git](https://git-scm.com/) installed
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
|
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
|
||||||
@@ -80,7 +84,7 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
|
|||||||
If you have any questions, suggestions or comments, you have several options:
|
If you have any questions, suggestions or comments, you have several options:
|
||||||
|
|
||||||
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
|
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
|
||||||
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
|
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time)
|
||||||
|
|
||||||
Feel free to ask questions or share your ideas - we'd love to hear from you!
|
Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||||
|
|
||||||
@@ -167,7 +171,7 @@ Here's how you can contribute:
|
|||||||
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
|
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
[](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
|
[](https://star-history.com/#bluewave-labs/Checkmate&Date)
|
||||||
|
|
||||||
## Our sponsors
|
## Our sponsors
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { PulseDot, Dot } from "@/Components/design-elements";
|
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
||||||
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import prettyMilliseconds from "pretty-ms";
|
import prettyMilliseconds from "pretty-ms";
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
@@ -28,7 +29,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
alignItems={"center"}
|
alignItems={"center"}
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
>
|
>
|
||||||
<PulseDot color={getStatusColor(monitor.status, theme)} />
|
<PulseDot color={getStatusColor(monitor.status, theme)} />
|
||||||
<Typography
|
<Typography
|
||||||
@@ -41,6 +42,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
>
|
>
|
||||||
{formatUrl(monitor?.url)}
|
{formatUrl(monitor?.url)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{monitor.type === "pagespeed" && monitor.strategy && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<StrategyBadge strategy={monitor.strategy} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!isSmall && (
|
{!isSmall && (
|
||||||
<>
|
<>
|
||||||
<Dot />
|
<Dot />
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { PageSpeedStrategy } from "@/Types/Monitor";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
|
interface StrategyBadgeProps {
|
||||||
|
strategy: PageSpeedStrategy | undefined;
|
||||||
|
variant?: "caption" | "body2";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StrategyBadge = ({ strategy, variant = "body2" }: StrategyBadgeProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!strategy) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
variant={variant}
|
||||||
|
px={SPACING.XXL}
|
||||||
|
py={SPACING.SM}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.secondary.light}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{t(`pages.pageSpeed.strategy.${strategy}`)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,3 +21,4 @@ export * from "./SplitBox";
|
|||||||
export * from "./TextLink";
|
export * from "./TextLink";
|
||||||
export * from "./OfflineBanner";
|
export * from "./OfflineBanner";
|
||||||
export * from "./Avatar";
|
export * from "./Avatar";
|
||||||
|
export * from "./StrategyBadge";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
||||||
|
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
|
||||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||||
|
|
||||||
interface UseMonitorFormOptions {
|
interface UseMonitorFormOptions {
|
||||||
@@ -88,6 +89,7 @@ export const useMonitorForm = ({
|
|||||||
...base,
|
...base,
|
||||||
type: "pagespeed",
|
type: "pagespeed",
|
||||||
url: data?.url || "",
|
url: data?.url || "",
|
||||||
|
strategy: data?.strategy ?? DefaultPageSpeedStrategy,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "hardware":
|
case "hardware":
|
||||||
|
|||||||
@@ -1,28 +1,76 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { notificationSchema } from "@/Validation/notifications";
|
import { notificationSchema } from "@/Validation/notifications";
|
||||||
|
import type { NotificationFormData } from "@/Validation/notifications";
|
||||||
import type { Notification } from "@/Types/Notification";
|
import type { Notification } from "@/Types/Notification";
|
||||||
|
|
||||||
interface UseNotificationFormOptions {
|
interface UseNotificationFormOptions {
|
||||||
data?: Notification | null;
|
data?: Notification | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDefaults(data: Notification | null): NotificationFormData {
|
||||||
|
if (data?.type === "matrix") {
|
||||||
|
return {
|
||||||
|
type: "matrix",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
homeserverUrl: data.homeserverUrl || "",
|
||||||
|
roomId: data.roomId || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "telegram") {
|
||||||
|
return {
|
||||||
|
type: "telegram",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "slack") {
|
||||||
|
return {
|
||||||
|
type: "slack",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "discord") {
|
||||||
|
return {
|
||||||
|
type: "discord",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "webhook") {
|
||||||
|
return {
|
||||||
|
type: "webhook",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "pager_duty") {
|
||||||
|
return {
|
||||||
|
type: "pager_duty",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "teams") {
|
||||||
|
return {
|
||||||
|
type: "teams",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Default: email (covers both data === null and data.type === "email")
|
||||||
|
return {
|
||||||
|
type: "email",
|
||||||
|
notificationName: data?.notificationName || "",
|
||||||
|
address: data?.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
|
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const defaults =
|
const defaults = buildDefaults(data);
|
||||||
data?.type === "matrix"
|
|
||||||
? {
|
|
||||||
type: "matrix" as const,
|
|
||||||
notificationName: data.notificationName || "",
|
|
||||||
homeserverUrl: data.homeserverUrl || "",
|
|
||||||
roomId: data.roomId || "",
|
|
||||||
accessToken: data.accessToken || "",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
|
|
||||||
notificationName: data?.notificationName || "",
|
|
||||||
address: data?.address || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
return { schema: notificationSchema, defaults };
|
return { schema: notificationSchema, defaults };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
};
|
};
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
type MonitorType,
|
type MonitorType,
|
||||||
type GamesMap,
|
type GamesMap,
|
||||||
supportsGeoCheck,
|
supportsGeoCheck,
|
||||||
|
DefaultPageSpeedStrategy,
|
||||||
} from "@/Types/Monitor";
|
} from "@/Types/Monitor";
|
||||||
import type { Notification } from "@/Types/Notification";
|
import type { Notification } from "@/Types/Notification";
|
||||||
import type { MonitorFormData } from "@/Validation/monitor";
|
import type { MonitorFormData } from "@/Validation/monitor";
|
||||||
@@ -51,6 +52,7 @@ interface GeneralSettingsConfig {
|
|||||||
showSecret: boolean;
|
showSecret: boolean;
|
||||||
showGrpcServiceName: boolean;
|
showGrpcServiceName: boolean;
|
||||||
showIgnoreTls: boolean;
|
showIgnoreTls: boolean;
|
||||||
|
showStrategy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGeneralSettingsConfig = (
|
const getGeneralSettingsConfig = (
|
||||||
@@ -68,6 +70,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
},
|
},
|
||||||
ping: {
|
ping: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||||
@@ -79,6 +82,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
},
|
},
|
||||||
docker: {
|
docker: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
||||||
@@ -90,6 +94,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
},
|
},
|
||||||
port: {
|
port: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||||
@@ -101,6 +106,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||||
@@ -112,6 +118,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
},
|
},
|
||||||
grpc: {
|
grpc: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||||
@@ -123,6 +130,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: true,
|
showGrpcServiceName: true,
|
||||||
showIgnoreTls: true,
|
showIgnoreTls: true,
|
||||||
|
showStrategy: false,
|
||||||
},
|
},
|
||||||
pagespeed: {
|
pagespeed: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||||
@@ -134,6 +142,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: true,
|
||||||
},
|
},
|
||||||
hardware: {
|
hardware: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||||
@@ -145,6 +154,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: true,
|
showSecret: true,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
},
|
},
|
||||||
websocket: {
|
websocket: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
|
||||||
@@ -156,6 +166,7 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: true,
|
showIgnoreTls: true,
|
||||||
|
showStrategy: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return configs[type] || configs.http;
|
return configs[type] || configs.http;
|
||||||
@@ -382,6 +393,31 @@ const CreateMonitorPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Strategy field - only for pagespeed type */}
|
||||||
|
{generalSettingsConfig.showStrategy && (
|
||||||
|
<Controller
|
||||||
|
name="strategy"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? DefaultPageSpeedStrategy}
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.createMonitor.form.general.option.strategy.label"
|
||||||
|
)}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
>
|
||||||
|
<MenuItem value="desktop">
|
||||||
|
{t("pages.createMonitor.form.general.option.strategy.desktop")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="mobile">
|
||||||
|
{t("pages.createMonitor.form.general.option.strategy.mobile")}
|
||||||
|
</MenuItem>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Port field - only for port and game types */}
|
{/* Port field - only for port and game types */}
|
||||||
{generalSettingsConfig.showPort && (
|
{generalSettingsConfig.showPort && (
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{watchedType !== "matrix" && (
|
{watchedType !== "matrix" && watchedType !== "telegram" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={addressConfig.title}
|
title={addressConfig.title}
|
||||||
subtitle={addressConfig.description}
|
subtitle={addressConfig.description}
|
||||||
@@ -155,7 +155,7 @@ const NotificationsCreatePage = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="address"
|
name="address"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={defaults.address}
|
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
@@ -171,6 +171,49 @@ const NotificationsCreatePage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{watchedType === "telegram" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.telegram.title")}
|
||||||
|
subtitle={t("pages.notifications.form.telegram.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(8)}>
|
||||||
|
<Controller
|
||||||
|
name="accessToken"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.telegram.placeholderBotToken"
|
||||||
|
)}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="address"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
|
||||||
|
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{watchedType === "matrix" && (
|
{watchedType === "matrix" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.notifications.form.matrix.title")}
|
title={t("pages.notifications.form.matrix.title")}
|
||||||
@@ -180,7 +223,7 @@ const NotificationsCreatePage = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="homeserverUrl"
|
name="homeserverUrl"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={defaults.homeserverUrl}
|
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
@@ -196,7 +239,7 @@ const NotificationsCreatePage = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="roomId"
|
name="roomId"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={defaults.roomId}
|
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
@@ -212,7 +255,7 @@ const NotificationsCreatePage = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="accessToken"
|
name="accessToken"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={defaults.accessToken}
|
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
import {
|
||||||
|
Table,
|
||||||
|
Pagination,
|
||||||
|
StatusLabel,
|
||||||
|
StrategyBadge,
|
||||||
|
} from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { HistogramPageSpeed } from "@/Components/monitors";
|
import { HistogramPageSpeed } from "@/Components/monitors";
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
@@ -179,7 +185,19 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
),
|
),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
return row.name;
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={LAYOUT.XS}
|
||||||
|
>
|
||||||
|
<Typography>{row.name}</Typography>
|
||||||
|
<StrategyBadge
|
||||||
|
strategy={row.strategy}
|
||||||
|
variant="caption"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
|
|||||||
|
|
||||||
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
||||||
|
|
||||||
|
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
||||||
|
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
||||||
|
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
|
||||||
|
|
||||||
export interface Monitor {
|
export interface Monitor {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -72,6 +76,7 @@ export interface Monitor {
|
|||||||
selectedDisks: string[];
|
selectedDisks: string[];
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
grpcServiceName?: string;
|
grpcServiceName?: string;
|
||||||
|
strategy?: PageSpeedStrategy;
|
||||||
group: string | null;
|
group: string | null;
|
||||||
geoCheckEnabled?: boolean;
|
geoCheckEnabled?: boolean;
|
||||||
geoCheckLocations?: GeoContinent[];
|
geoCheckLocations?: GeoContinent[];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const NotificationChannels = [
|
|||||||
"pager_duty",
|
"pager_duty",
|
||||||
"matrix",
|
"matrix",
|
||||||
"teams",
|
"teams",
|
||||||
|
"telegram",
|
||||||
] as const;
|
] as const;
|
||||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { GeoContinents } from "@/Types/GeoCheck";
|
import { GeoContinents } from "@/Types/GeoCheck";
|
||||||
|
import { PageSpeedStrategies } from "@/Types/Monitor";
|
||||||
|
|
||||||
// URL schema with custom error message
|
// URL schema with custom error message
|
||||||
const urlSchema = z.url({ message: "Please enter a valid URL" });
|
const urlSchema = z.url({ message: "Please enter a valid URL" });
|
||||||
@@ -89,6 +90,7 @@ const grpcSchema = baseSchema.extend({
|
|||||||
const pagespeedSchema = baseSchema.extend({
|
const pagespeedSchema = baseSchema.extend({
|
||||||
type: z.literal("pagespeed"),
|
type: z.literal("pagespeed"),
|
||||||
url: urlSchema,
|
url: urlSchema,
|
||||||
|
strategy: z.enum(PageSpeedStrategies),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hardware/Infrastructure monitor schema
|
// Hardware/Infrastructure monitor schema
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ const teamsSchema = baseSchema.extend({
|
|||||||
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
|
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const telegramSchema = baseSchema.extend({
|
||||||
|
type: z.literal("telegram"),
|
||||||
|
address: z.string().min(1, "Chat ID is required"),
|
||||||
|
accessToken: z.string().min(1, "Bot token is required"),
|
||||||
|
});
|
||||||
|
|
||||||
export const notificationSchema = z.discriminatedUnion("type", [
|
export const notificationSchema = z.discriminatedUnion("type", [
|
||||||
emailSchema,
|
emailSchema,
|
||||||
slackSchema,
|
slackSchema,
|
||||||
@@ -58,6 +64,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
pagerDutySchema,
|
pagerDutySchema,
|
||||||
matrixSchema,
|
matrixSchema,
|
||||||
teamsSchema,
|
teamsSchema,
|
||||||
|
telegramSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
||||||
@@ -503,6 +503,11 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"strategy": {
|
||||||
|
"label": "Strategy",
|
||||||
|
"desktop": "Desktop",
|
||||||
|
"mobile": "Mobile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "General settings",
|
"title": "General settings",
|
||||||
@@ -951,6 +956,14 @@
|
|||||||
"description": "Select the type of notification channel to create.",
|
"description": "Select the type of notification channel to create.",
|
||||||
"optionType": "Type",
|
"optionType": "Type",
|
||||||
"title": "Channel type"
|
"title": "Channel type"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"title": "Telegram configuration",
|
||||||
|
"description": "Configure your Telegram bot to send notifications.",
|
||||||
|
"optionBotToken": "Bot token",
|
||||||
|
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||||
|
"optionChatId": "Chat ID",
|
||||||
|
"placeholderChatId": "-1001234567890"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -960,6 +973,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pageSpeed": {
|
"pageSpeed": {
|
||||||
|
"strategy": {
|
||||||
|
"desktop": "Desktop",
|
||||||
|
"mobile": "Mobile"
|
||||||
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"common": {
|
"common": {
|
||||||
"cls": "Cumulative Layout Shift (CLS)",
|
"cls": "Cumulative Layout Shift (CLS)",
|
||||||
|
|||||||
@@ -3631,6 +3631,12 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
|
"strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["desktop", "mobile"],
|
||||||
|
"default": "desktop",
|
||||||
|
"description": "PageSpeed analysis strategy"
|
||||||
|
},
|
||||||
"selectedDisks": {
|
"selectedDisks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -3737,6 +3743,11 @@
|
|||||||
"grpcServiceName": {
|
"grpcServiceName": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["desktop", "mobile"],
|
||||||
|
"description": "PageSpeed analysis strategy"
|
||||||
|
},
|
||||||
"selectedDisks": {
|
"selectedDisks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
Generated
+168
@@ -37,6 +37,7 @@
|
|||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "8.0.1",
|
"nodemailer": "8.0.1",
|
||||||
|
"pg": "8.20.0",
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"sharp": "0.33.5",
|
"sharp": "0.33.5",
|
||||||
"ssl-checker": "2.0.10",
|
"ssl-checker": "2.0.10",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/nodemailer": "7.0.1",
|
"@types/nodemailer": "7.0.1",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/pg": "8.20.0",
|
||||||
"@types/ping": "0.4.4",
|
"@types/ping": "0.4.4",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
@@ -873,6 +875,7 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@@ -1502,6 +1505,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -1546,6 +1550,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -4337,6 +4342,7 @@
|
|||||||
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -4497,6 +4503,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||||
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -4522,6 +4529,18 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/ping": {
|
"node_modules/@types/ping": {
|
||||||
"version": "0.4.4",
|
"version": "0.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ping/-/ping-0.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ping/-/ping-0.4.4.tgz",
|
||||||
@@ -4734,6 +4753,7 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -5347,6 +5367,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5925,6 +5946,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
@@ -6867,6 +6889,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
|
||||||
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
|
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssnano-preset-default": "^7.0.9",
|
"cssnano-preset-default": "^7.0.9",
|
||||||
"lilconfig": "^3.1.3"
|
"lilconfig": "^3.1.3"
|
||||||
@@ -7634,6 +7657,7 @@
|
|||||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -7995,6 +8019,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -9639,6 +9664,7 @@
|
|||||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "30.2.0",
|
"@jest/core": "30.2.0",
|
||||||
"@jest/types": "30.2.0",
|
"@jest/types": "30.2.0",
|
||||||
@@ -12358,6 +12384,96 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.12.0",
|
||||||
|
"pg-pool": "^3.13.0",
|
||||||
|
"pg-protocol": "^1.13.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -12509,6 +12625,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -12982,6 +13099,45 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/posthtml": {
|
"node_modules/posthtml": {
|
||||||
"version": "0.16.7",
|
"version": "0.16.7",
|
||||||
"resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz",
|
"resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz",
|
||||||
@@ -14027,6 +14183,15 @@
|
|||||||
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
|
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
@@ -14604,6 +14769,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -14772,6 +14938,7 @@
|
|||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
@@ -14934,6 +15101,7 @@
|
|||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "8.0.1",
|
"nodemailer": "8.0.1",
|
||||||
|
"pg": "8.20.0",
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"sharp": "0.33.5",
|
"sharp": "0.33.5",
|
||||||
"ssl-checker": "2.0.10",
|
"ssl-checker": "2.0.10",
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/nodemailer": "7.0.1",
|
"@types/nodemailer": "7.0.1",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/pg": "8.20.0",
|
||||||
"@types/ping": "0.4.4",
|
"@types/ping": "0.4.4",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
PagerDutyProvider,
|
PagerDutyProvider,
|
||||||
MatrixProvider,
|
MatrixProvider,
|
||||||
TeamsProvider,
|
TeamsProvider,
|
||||||
|
TelegramProvider,
|
||||||
// Interfaces
|
// Interfaces
|
||||||
INetworkService,
|
INetworkService,
|
||||||
IEmailService,
|
IEmailService,
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
IIncidentService,
|
IIncidentService,
|
||||||
INotificationMessageBuilder,
|
INotificationMessageBuilder,
|
||||||
ISettingsService,
|
ISettingsService,
|
||||||
|
SettingsService,
|
||||||
EnvConfig,
|
EnvConfig,
|
||||||
} from "@/service/index.js";
|
} from "@/service/index.js";
|
||||||
|
|
||||||
@@ -92,9 +94,23 @@ import {
|
|||||||
MongoInvitesRepository,
|
MongoInvitesRepository,
|
||||||
MongoRecoveryTokensRepository,
|
MongoRecoveryTokensRepository,
|
||||||
MongoNotificationsRepository,
|
MongoNotificationsRepository,
|
||||||
MongoIncidentRepository,
|
MongoIncidentsRepository,
|
||||||
MongoTeamsRepository,
|
MongoTeamsRepository,
|
||||||
MongoMaintenanceWindowsRepository,
|
MongoMaintenanceWindowsRepository,
|
||||||
|
MongoSettingsRepository,
|
||||||
|
TimescaleMonitorsRepository,
|
||||||
|
TimescaleChecksRepository,
|
||||||
|
TimescaleGeoChecksRepository,
|
||||||
|
TimescaleMonitorStatsRepository,
|
||||||
|
TimescaleStatusPagesRepository,
|
||||||
|
TimescaleUsersRepository,
|
||||||
|
TimescaleInvitesRepository,
|
||||||
|
TimescaleRecoveryTokensRepository,
|
||||||
|
TimescaleNotificationsRepository,
|
||||||
|
TimescaleIncidentsRepository,
|
||||||
|
TimescaleTeamsRepository,
|
||||||
|
TimescaleMaintenanceWindowsRepository,
|
||||||
|
TimescaleSettingsRepository,
|
||||||
IMonitorsRepository,
|
IMonitorsRepository,
|
||||||
IChecksRepository,
|
IChecksRepository,
|
||||||
IGeoChecksRepository,
|
IGeoChecksRepository,
|
||||||
@@ -110,6 +126,8 @@ import {
|
|||||||
IMaintenanceWindowsRepository,
|
IMaintenanceWindowsRepository,
|
||||||
} from "@/repositories/index.js";
|
} from "@/repositories/index.js";
|
||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
|
import TimescaleDB from "@/db/TimescaleDB.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
export type InitializedServices = {
|
export type InitializedServices = {
|
||||||
settingsService: ISettingsService;
|
settingsService: ISettingsService;
|
||||||
@@ -152,32 +170,81 @@ export const initializeServices = async ({
|
|||||||
logger,
|
logger,
|
||||||
envSettings,
|
envSettings,
|
||||||
settingsService,
|
settingsService,
|
||||||
settingsRepository,
|
|
||||||
}: {
|
}: {
|
||||||
logger: ILogger;
|
logger: ILogger;
|
||||||
envSettings: EnvConfig;
|
envSettings: EnvConfig;
|
||||||
settingsService: ISettingsService;
|
settingsService: ISettingsService;
|
||||||
settingsRepository: ISettingsRepository;
|
|
||||||
}): Promise<InitializedServices> => {
|
}): Promise<InitializedServices> => {
|
||||||
// Create DB
|
// Create DB
|
||||||
|
|
||||||
const db = new MongoDB(logger, envSettings);
|
const dbType = envSettings.dbType;
|
||||||
|
|
||||||
|
let db: IDb | null = null;
|
||||||
|
|
||||||
|
if (dbType === "mongodb") {
|
||||||
|
db = new MongoDB(logger, envSettings);
|
||||||
|
} else if (dbType === "timescaledb") {
|
||||||
|
db = new TimescaleDB(logger, envSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
throw new AppError({ message: "Unsupported database type", status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
await db.connect();
|
await db.connect();
|
||||||
|
|
||||||
|
let monitorsRepository: IMonitorsRepository;
|
||||||
|
let checksRepository: IChecksRepository;
|
||||||
|
let geoChecksRepository: IGeoChecksRepository;
|
||||||
|
let monitorStatsRepository: IMonitorStatsRepository;
|
||||||
|
let statusPagesRepository: IStatusPagesRepository;
|
||||||
|
let usersRepository: IUsersRepository;
|
||||||
|
let invitesRepository: IInvitesRepository;
|
||||||
|
let recoveryTokensRepository: IRecoveryTokensRepository;
|
||||||
|
let settingsRepository: ISettingsRepository;
|
||||||
|
let notificationsRepository: INotificationsRepository;
|
||||||
|
let incidentsRepository: IIncidentsRepository;
|
||||||
|
let teamsRepository: ITeamsRepository;
|
||||||
|
let maintenanceWindowsRepository: IMaintenanceWindowsRepository;
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
const monitorsRepository = new MongoMonitorsRepository();
|
|
||||||
const checksRepository = new MongoChecksRepository(logger);
|
if (dbType === "mongodb") {
|
||||||
const geoChecksRepository = new MongoGeoChecksRepository(logger);
|
monitorsRepository = new MongoMonitorsRepository();
|
||||||
const monitorStatsRepository = new MongoMonitorStatsRepository();
|
checksRepository = new MongoChecksRepository(logger);
|
||||||
const statusPagesRepository = new MongoStatusPagesRepository();
|
geoChecksRepository = new MongoGeoChecksRepository(logger);
|
||||||
const usersRepository = new MongoUsersRepository();
|
monitorStatsRepository = new MongoMonitorStatsRepository();
|
||||||
const invitesRepository = new MongoInvitesRepository();
|
statusPagesRepository = new MongoStatusPagesRepository();
|
||||||
const recoveryTokensRepository = new MongoRecoveryTokensRepository();
|
usersRepository = new MongoUsersRepository();
|
||||||
const notificationsRepository = new MongoNotificationsRepository();
|
invitesRepository = new MongoInvitesRepository();
|
||||||
const incidentsRepository = new MongoIncidentRepository();
|
recoveryTokensRepository = new MongoRecoveryTokensRepository();
|
||||||
const teamsRepository = new MongoTeamsRepository();
|
settingsRepository = new MongoSettingsRepository();
|
||||||
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
|
notificationsRepository = new MongoNotificationsRepository();
|
||||||
|
incidentsRepository = new MongoIncidentsRepository();
|
||||||
|
teamsRepository = new MongoTeamsRepository();
|
||||||
|
maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
|
||||||
|
} else {
|
||||||
|
const pool = db.getPool();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error("Failed to get database pool");
|
||||||
|
}
|
||||||
|
monitorsRepository = new TimescaleMonitorsRepository(pool);
|
||||||
|
checksRepository = new TimescaleChecksRepository(pool);
|
||||||
|
geoChecksRepository = new TimescaleGeoChecksRepository(pool);
|
||||||
|
monitorStatsRepository = new TimescaleMonitorStatsRepository(pool);
|
||||||
|
statusPagesRepository = new TimescaleStatusPagesRepository(pool);
|
||||||
|
usersRepository = new TimescaleUsersRepository(pool);
|
||||||
|
invitesRepository = new TimescaleInvitesRepository(pool);
|
||||||
|
recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool);
|
||||||
|
settingsRepository = new TimescaleSettingsRepository(pool);
|
||||||
|
notificationsRepository = new TimescaleNotificationsRepository(pool);
|
||||||
|
incidentsRepository = new TimescaleIncidentsRepository(pool);
|
||||||
|
teamsRepository = new TimescaleTeamsRepository(pool);
|
||||||
|
maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject settings repository into settings service (now that DB is connected)
|
||||||
|
(settingsService as SettingsService).setRepository(settingsRepository);
|
||||||
|
|
||||||
// Network providers
|
// Network providers
|
||||||
const pingProvider = new PingProvider(ping);
|
const pingProvider = new PingProvider(ping);
|
||||||
@@ -230,6 +297,7 @@ export const initializeServices = async ({
|
|||||||
const pagerDutyProvider = new PagerDutyProvider(logger);
|
const pagerDutyProvider = new PagerDutyProvider(logger);
|
||||||
const matrixProvider = new MatrixProvider(logger);
|
const matrixProvider = new MatrixProvider(logger);
|
||||||
const teamsProvider = new TeamsProvider(logger);
|
const teamsProvider = new TeamsProvider(logger);
|
||||||
|
const telegramProvider = new TelegramProvider(logger);
|
||||||
|
|
||||||
const notificationsService = new NotificationsService(
|
const notificationsService = new NotificationsService(
|
||||||
notificationsRepository,
|
notificationsRepository,
|
||||||
@@ -241,6 +309,7 @@ export const initializeServices = async ({
|
|||||||
pagerDutyProvider,
|
pagerDutyProvider,
|
||||||
matrixProvider,
|
matrixProvider,
|
||||||
teamsProvider,
|
teamsProvider,
|
||||||
|
telegramProvider,
|
||||||
settingsService,
|
settingsService,
|
||||||
logger,
|
logger,
|
||||||
notificationMessageBuilder
|
notificationMessageBuilder
|
||||||
|
|||||||
@@ -26,16 +26,6 @@ class GeoCheckController implements IGeoCheckController {
|
|||||||
const validatedParams = getChecksParamValidation.parse(req.params);
|
const validatedParams = getChecksParamValidation.parse(req.params);
|
||||||
const validatedQuery = getChecksQueryValidation.parse(req.query);
|
const validatedQuery = getChecksQueryValidation.parse(req.query);
|
||||||
|
|
||||||
if (!validatedQuery.continent || validatedQuery.continent.length === 0) {
|
|
||||||
throw new AppError({
|
|
||||||
message: "At least one continent must be specified",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "getGeoChecksByMonitor",
|
|
||||||
details: { monitorId: validatedParams.monitorId },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamId = requireTeamId(req.user?.teamId);
|
const teamId = requireTeamId(req.user?.teamId);
|
||||||
|
|
||||||
const result = await this.geoChecksService.getGeoChecksByMonitor({
|
const result = await this.geoChecksService.getGeoChecksByMonitor({
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
export interface IDb {
|
export interface IDb {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
|
getPool(): Pool | null;
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ILogger } from "@/utils/logger.js";
|
|||||||
import { EnvConfig } from "@/service/system/settingsService.js";
|
import { EnvConfig } from "@/service/system/settingsService.js";
|
||||||
const SERVICE_NAME = "MongoDB";
|
const SERVICE_NAME = "MongoDB";
|
||||||
import { IDb } from "@/db/IDb.js";
|
import { IDb } from "@/db/IDb.js";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
class MongoDB implements IDb {
|
class MongoDB implements IDb {
|
||||||
static SERVICE_NAME = SERVICE_NAME;
|
static SERVICE_NAME = SERVICE_NAME;
|
||||||
@@ -74,6 +75,10 @@ class MongoDB implements IDb {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getPool = () => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MongoDB;
|
export default MongoDB;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { IDb } from "@/db/IDb.js";
|
||||||
|
import { ILogger } from "@/utils/logger.js";
|
||||||
|
import { EnvConfig } from "@/service/system/settingsService.js";
|
||||||
|
import { Client, Pool } from "pg";
|
||||||
|
import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js";
|
||||||
|
|
||||||
|
const SERVICE_NAME = "TimescaleDB";
|
||||||
|
|
||||||
|
class TimescaleDB implements IDb {
|
||||||
|
private pool: Pool | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private logger: ILogger,
|
||||||
|
private envSettings: EnvConfig
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private ensureDatabaseExists = async (connectionString: string) => {
|
||||||
|
const url = new URL(connectionString);
|
||||||
|
const dbName = url.pathname.slice(1); // Remove leading "/"
|
||||||
|
|
||||||
|
// Connect to the default "postgres" database
|
||||||
|
url.pathname = "/postgres";
|
||||||
|
const client = new Client({ connectionString: url.toString() });
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
await client.query(`CREATE DATABASE "${dbName}"`);
|
||||||
|
this.logger.info({
|
||||||
|
message: `Created database "${dbName}"`,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "ensureDatabaseExists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await client.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
connect = async () => {
|
||||||
|
try {
|
||||||
|
const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate";
|
||||||
|
|
||||||
|
await this.ensureDatabaseExists(connectionString);
|
||||||
|
|
||||||
|
this.pool = new Pool({ connectionString });
|
||||||
|
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
client.release();
|
||||||
|
|
||||||
|
// Ensure TimescaleDB extension is enabled
|
||||||
|
await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;");
|
||||||
|
|
||||||
|
this.logger.info({
|
||||||
|
message: "Connected to TimescaleDB",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "connect",
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTimescaleDBMigrations(this.pool, this.logger);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error({
|
||||||
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "connect",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
disconnect = async () => {
|
||||||
|
try {
|
||||||
|
if (this.pool === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.info({
|
||||||
|
message: "Disconnecting from TimescaleDB",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "disconnect",
|
||||||
|
});
|
||||||
|
await this.pool.end();
|
||||||
|
this.pool = null;
|
||||||
|
this.logger.info({
|
||||||
|
message: "Disconnected from TimescaleDB",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "disconnect",
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error({
|
||||||
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "disconnect",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getPool = (): Pool => {
|
||||||
|
if (this.pool === null) {
|
||||||
|
throw new Error("TimescaleDB is not connected. Call connect() first.");
|
||||||
|
}
|
||||||
|
return this.pool;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimescaleDB;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createEnums = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropEnums = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
DROP TYPE IF EXISTS match_method;
|
||||||
|
DROP TYPE IF EXISTS status_page_type;
|
||||||
|
DROP TYPE IF EXISTS duration_unit;
|
||||||
|
DROP TYPE IF EXISTS incident_resolution_type;
|
||||||
|
DROP TYPE IF EXISTS user_role;
|
||||||
|
DROP TYPE IF EXISTS notification_type;
|
||||||
|
DROP TYPE IF EXISTS monitor_status;
|
||||||
|
DROP TYPE IF EXISTS monitor_type;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createTeams = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropTeams = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS teams;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createUsers = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
avatar_image TEXT,
|
||||||
|
profile_image BYTEA,
|
||||||
|
profile_image_content_type TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
|
||||||
|
check_ttl INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropUsers = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS users;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createMonitors = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitors (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
type monitor_type NOT NULL,
|
||||||
|
status monitor_status NOT NULL DEFAULT 'initializing',
|
||||||
|
|
||||||
|
-- HTTP / Website monitoring
|
||||||
|
url TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
ignore_tls_errors BOOLEAN DEFAULT FALSE,
|
||||||
|
use_advanced_matching BOOLEAN DEFAULT FALSE,
|
||||||
|
json_path TEXT,
|
||||||
|
expected_value TEXT,
|
||||||
|
match_method match_method,
|
||||||
|
secret TEXT,
|
||||||
|
|
||||||
|
-- Scheduling
|
||||||
|
interval_ms INTEGER NOT NULL DEFAULT 60000,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Uptime tracking
|
||||||
|
status_window BOOLEAN[],
|
||||||
|
status_window_size INTEGER DEFAULT 5,
|
||||||
|
status_window_threshold INTEGER DEFAULT 60,
|
||||||
|
uptime_percentage DOUBLE PRECISION,
|
||||||
|
|
||||||
|
-- Infrastructure alert thresholds
|
||||||
|
cpu_alert_threshold INTEGER,
|
||||||
|
cpu_alert_counter INTEGER,
|
||||||
|
memory_alert_threshold INTEGER,
|
||||||
|
memory_alert_counter INTEGER,
|
||||||
|
disk_alert_threshold INTEGER,
|
||||||
|
disk_alert_counter INTEGER,
|
||||||
|
temp_alert_threshold INTEGER,
|
||||||
|
temp_alert_counter INTEGER,
|
||||||
|
selected_disks TEXT[],
|
||||||
|
|
||||||
|
-- Game monitoring
|
||||||
|
game_id TEXT,
|
||||||
|
grpc_service_name TEXT,
|
||||||
|
|
||||||
|
-- Grouping
|
||||||
|
monitor_group TEXT,
|
||||||
|
|
||||||
|
-- Geo checks
|
||||||
|
geo_check_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
geo_check_locations TEXT[],
|
||||||
|
geo_check_interval_ms INTEGER DEFAULT 300000,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropMonitors = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS monitors;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createNotifications = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id),
|
||||||
|
type notification_type NOT NULL,
|
||||||
|
notification_name TEXT,
|
||||||
|
address TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
|
||||||
|
-- Matrix-specific fields
|
||||||
|
homeserver_url TEXT,
|
||||||
|
room_id TEXT,
|
||||||
|
access_token TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropNotifications = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS notifications;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createMonitorNotifications = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitor_notifications (
|
||||||
|
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (monitor_id, notification_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification
|
||||||
|
ON monitor_notifications (notification_id);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropMonitorNotifications = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createIncidents = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS incidents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
status BOOLEAN,
|
||||||
|
message TEXT,
|
||||||
|
status_code INTEGER,
|
||||||
|
resolution_type incident_resolution_type,
|
||||||
|
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
resolved_by_email TEXT,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropIncidents = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS incidents;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createMonitorStats = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitor_stats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
avg_response_time DOUBLE PRECISION DEFAULT 0,
|
||||||
|
max_response_time DOUBLE PRECISION DEFAULT 0,
|
||||||
|
total_checks BIGINT DEFAULT 0,
|
||||||
|
total_up_checks BIGINT DEFAULT 0,
|
||||||
|
total_down_checks BIGINT DEFAULT 0,
|
||||||
|
uptime_percentage DOUBLE PRECISION DEFAULT 0,
|
||||||
|
last_check_timestamp TIMESTAMPTZ,
|
||||||
|
last_response_time DOUBLE PRECISION DEFAULT 0,
|
||||||
|
time_of_last_failure TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropMonitorStats = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS monitor_stats;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createChecks = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS checks (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
monitor_id UUID NOT NULL,
|
||||||
|
team_id UUID NOT NULL,
|
||||||
|
monitor_type monitor_type NOT NULL,
|
||||||
|
status BOOLEAN,
|
||||||
|
response_time INTEGER,
|
||||||
|
status_code INTEGER,
|
||||||
|
message TEXT,
|
||||||
|
|
||||||
|
-- HTTP request timings
|
||||||
|
timing_start DOUBLE PRECISION,
|
||||||
|
timing_socket DOUBLE PRECISION,
|
||||||
|
timing_lookup DOUBLE PRECISION,
|
||||||
|
timing_connect DOUBLE PRECISION,
|
||||||
|
timing_secure_connect DOUBLE PRECISION,
|
||||||
|
timing_upload DOUBLE PRECISION,
|
||||||
|
timing_response DOUBLE PRECISION,
|
||||||
|
timing_end DOUBLE PRECISION,
|
||||||
|
phase_wait DOUBLE PRECISION,
|
||||||
|
phase_dns DOUBLE PRECISION,
|
||||||
|
phase_tcp DOUBLE PRECISION,
|
||||||
|
phase_tls DOUBLE PRECISION,
|
||||||
|
phase_request DOUBLE PRECISION,
|
||||||
|
phase_first_byte DOUBLE PRECISION,
|
||||||
|
phase_download DOUBLE PRECISION,
|
||||||
|
phase_total DOUBLE PRECISION,
|
||||||
|
|
||||||
|
-- CPU metrics
|
||||||
|
cpu_physical_core INTEGER,
|
||||||
|
cpu_logical_core INTEGER,
|
||||||
|
cpu_frequency DOUBLE PRECISION,
|
||||||
|
cpu_current_frequency DOUBLE PRECISION,
|
||||||
|
cpu_temperature DOUBLE PRECISION[],
|
||||||
|
cpu_free_percent DOUBLE PRECISION,
|
||||||
|
cpu_usage_percent DOUBLE PRECISION,
|
||||||
|
|
||||||
|
-- Memory metrics
|
||||||
|
mem_total_bytes BIGINT,
|
||||||
|
mem_available_bytes BIGINT,
|
||||||
|
mem_used_bytes BIGINT,
|
||||||
|
mem_usage_percent DOUBLE PRECISION,
|
||||||
|
|
||||||
|
-- Host info
|
||||||
|
host_os TEXT,
|
||||||
|
host_platform TEXT,
|
||||||
|
host_kernel_version TEXT,
|
||||||
|
host_pretty_name TEXT,
|
||||||
|
|
||||||
|
-- Capture agent info
|
||||||
|
capture_version TEXT,
|
||||||
|
capture_mode TEXT,
|
||||||
|
|
||||||
|
-- Lighthouse / PageSpeed scores
|
||||||
|
lighthouse_performance DOUBLE PRECISION,
|
||||||
|
lighthouse_accessibility DOUBLE PRECISION,
|
||||||
|
lighthouse_best_practices DOUBLE PRECISION,
|
||||||
|
lighthouse_seo DOUBLE PRECISION,
|
||||||
|
|
||||||
|
-- Lighthouse audit details
|
||||||
|
audit_cls_score DOUBLE PRECISION,
|
||||||
|
audit_cls_value DOUBLE PRECISION,
|
||||||
|
audit_cls_display TEXT,
|
||||||
|
audit_si_score DOUBLE PRECISION,
|
||||||
|
audit_si_value DOUBLE PRECISION,
|
||||||
|
audit_si_display TEXT,
|
||||||
|
audit_fcp_score DOUBLE PRECISION,
|
||||||
|
audit_fcp_value DOUBLE PRECISION,
|
||||||
|
audit_fcp_display TEXT,
|
||||||
|
audit_lcp_score DOUBLE PRECISION,
|
||||||
|
audit_lcp_value DOUBLE PRECISION,
|
||||||
|
audit_lcp_display TEXT,
|
||||||
|
audit_tbt_score DOUBLE PRECISION,
|
||||||
|
audit_tbt_value DOUBLE PRECISION,
|
||||||
|
audit_tbt_display TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('checks', 'created_at',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checks_monitor_time
|
||||||
|
ON checks (monitor_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checks_team_time
|
||||||
|
ON checks (team_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time
|
||||||
|
ON checks (monitor_id, monitor_type, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checks_team_status_time
|
||||||
|
ON checks (team_id, status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checks_status_code
|
||||||
|
ON checks (status_code, created_at DESC);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropChecks = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS checks;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createCheckDisks = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS check_disks (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
check_id UUID NOT NULL,
|
||||||
|
check_created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
device TEXT,
|
||||||
|
mountpoint TEXT,
|
||||||
|
total_bytes BIGINT,
|
||||||
|
free_bytes BIGINT,
|
||||||
|
used_bytes BIGINT,
|
||||||
|
usage_percent DOUBLE PRECISION,
|
||||||
|
total_inodes BIGINT,
|
||||||
|
free_inodes BIGINT,
|
||||||
|
used_inodes BIGINT,
|
||||||
|
inodes_usage_percent DOUBLE PRECISION,
|
||||||
|
read_bytes BIGINT,
|
||||||
|
write_bytes BIGINT,
|
||||||
|
read_time BIGINT,
|
||||||
|
write_time BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('check_disks', 'check_created_at',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_check_disks_check
|
||||||
|
ON check_disks (check_id, check_created_at);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropCheckDisks = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS check_disks;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createCheckNetworkInterfaces = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS check_network_interfaces (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
check_id UUID NOT NULL,
|
||||||
|
check_created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
bytes_sent BIGINT,
|
||||||
|
bytes_recv BIGINT,
|
||||||
|
packets_sent BIGINT,
|
||||||
|
packets_recv BIGINT,
|
||||||
|
err_in BIGINT,
|
||||||
|
err_out BIGINT,
|
||||||
|
drop_in BIGINT,
|
||||||
|
drop_out BIGINT,
|
||||||
|
fifo_in BIGINT,
|
||||||
|
fifo_out BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('check_network_interfaces', 'check_created_at',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_check_net_check
|
||||||
|
ON check_network_interfaces (check_id, check_created_at);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropCheckNetworkInterfaces = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createCheckErrors = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS check_errors (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
check_id UUID NOT NULL,
|
||||||
|
check_created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
metrics TEXT[],
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('check_errors', 'check_created_at',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_check_errors_check
|
||||||
|
ON check_errors (check_id, check_created_at);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropCheckErrors = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS check_errors;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createGeoChecks = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS geo_checks (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
monitor_id UUID NOT NULL,
|
||||||
|
team_id UUID NOT NULL,
|
||||||
|
monitor_type monitor_type NOT NULL,
|
||||||
|
expiry TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('geo_checks', 'created_at',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time
|
||||||
|
ON geo_checks (monitor_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time
|
||||||
|
ON geo_checks (team_id, created_at DESC);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropGeoChecks = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS geo_checks;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createGeoCheckResults = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS geo_check_results (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
geo_check_id UUID NOT NULL,
|
||||||
|
geo_check_created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status BOOLEAN,
|
||||||
|
status_code INTEGER,
|
||||||
|
|
||||||
|
-- Location
|
||||||
|
location_continent TEXT,
|
||||||
|
location_region TEXT,
|
||||||
|
location_country TEXT,
|
||||||
|
location_state TEXT,
|
||||||
|
location_city TEXT,
|
||||||
|
location_longitude DOUBLE PRECISION,
|
||||||
|
location_latitude DOUBLE PRECISION,
|
||||||
|
|
||||||
|
-- Timings
|
||||||
|
timing_total DOUBLE PRECISION,
|
||||||
|
timing_dns DOUBLE PRECISION,
|
||||||
|
timing_tcp DOUBLE PRECISION,
|
||||||
|
timing_tls DOUBLE PRECISION,
|
||||||
|
timing_first_byte DOUBLE PRECISION,
|
||||||
|
timing_download DOUBLE PRECISION
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('geo_check_results', 'geo_check_created_at',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_geo_results_check
|
||||||
|
ON geo_check_results (geo_check_id, geo_check_created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_geo_results_country
|
||||||
|
ON geo_check_results (location_country, geo_check_created_at DESC);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropGeoCheckResults = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS geo_check_results;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createInvites = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
expiry TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropInvites = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS invites;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createRecoveryTokens = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recovery_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
expiry TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropRecoveryTokens = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createMaintenanceWindows = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS maintenance_windows (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
name TEXT,
|
||||||
|
duration INTEGER,
|
||||||
|
duration_unit duration_unit,
|
||||||
|
repeat INTEGER,
|
||||||
|
start_time TIMESTAMPTZ,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
expiry TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropMaintenanceWindows = async (pool: Pool) => {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createStatusPages = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS status_pages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id),
|
||||||
|
types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[],
|
||||||
|
company_name TEXT,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
timezone TEXT,
|
||||||
|
color TEXT DEFAULT '#4169E1',
|
||||||
|
logo_data BYTEA,
|
||||||
|
logo_content_type TEXT,
|
||||||
|
is_published BOOLEAN DEFAULT FALSE,
|
||||||
|
show_charts BOOLEAN DEFAULT FALSE,
|
||||||
|
show_uptime_percentage BOOLEAN DEFAULT FALSE,
|
||||||
|
show_admin_login_link BOOLEAN DEFAULT FALSE,
|
||||||
|
show_infrastructure BOOLEAN DEFAULT FALSE,
|
||||||
|
custom_css TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS status_page_monitors (
|
||||||
|
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
||||||
|
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (status_page_id, monitor_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS status_page_sub_monitors (
|
||||||
|
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
||||||
|
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (status_page_id, monitor_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropStatusPages = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
DROP TABLE IF EXISTS status_page_sub_monitors;
|
||||||
|
DROP TABLE IF EXISTS status_page_monitors;
|
||||||
|
DROP TABLE IF EXISTS status_pages;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createAppSettings = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
check_ttl INTEGER DEFAULT 30,
|
||||||
|
language TEXT,
|
||||||
|
jwt_secret TEXT,
|
||||||
|
pagespeed_api_key TEXT,
|
||||||
|
|
||||||
|
-- SMTP / Email settings
|
||||||
|
system_email_host TEXT,
|
||||||
|
system_email_port INTEGER,
|
||||||
|
system_email_address TEXT,
|
||||||
|
system_email_password TEXT,
|
||||||
|
system_email_user TEXT,
|
||||||
|
system_email_connection_host TEXT,
|
||||||
|
system_email_tls_servername TEXT,
|
||||||
|
system_email_secure BOOLEAN,
|
||||||
|
system_email_pool BOOLEAN,
|
||||||
|
system_email_ignore_tls BOOLEAN,
|
||||||
|
system_email_require_tls BOOLEAN,
|
||||||
|
system_email_reject_unauthorized BOOLEAN,
|
||||||
|
|
||||||
|
show_url BOOLEAN,
|
||||||
|
version TEXT,
|
||||||
|
|
||||||
|
-- Global infrastructure thresholds
|
||||||
|
threshold_cpu_usage INTEGER,
|
||||||
|
threshold_memory_usage INTEGER,
|
||||||
|
threshold_disk_usage INTEGER,
|
||||||
|
threshold_temperature INTEGER,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enforce singleton: only one row allowed
|
||||||
|
CREATE OR REPLACE FUNCTION enforce_singleton_app_settings()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN
|
||||||
|
RAISE EXCEPTION 'app_settings allows only one row';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings;
|
||||||
|
CREATE TRIGGER trg_singleton_app_settings
|
||||||
|
BEFORE INSERT ON app_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings();
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropAppSettings = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
DROP TABLE IF EXISTS app_settings;
|
||||||
|
DROP FUNCTION IF EXISTS enforce_singleton_app_settings();
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createContinuousAggregates = async (pool: Pool) => {
|
||||||
|
// Uptime & Response Time (Hourly)
|
||||||
|
await pool.query(`
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
monitor_id,
|
||||||
|
time_bucket('1 hour', created_at) AS bucket,
|
||||||
|
COUNT(*) AS total_checks,
|
||||||
|
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
|
||||||
|
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
|
||||||
|
AVG(response_time) AS avg_response_time,
|
||||||
|
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
|
||||||
|
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
|
||||||
|
MAX(response_time) AS max_response_time,
|
||||||
|
MIN(response_time) AS min_response_time
|
||||||
|
FROM checks
|
||||||
|
GROUP BY monitor_id, bucket
|
||||||
|
WITH NO DATA;
|
||||||
|
`);
|
||||||
|
await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`);
|
||||||
|
await pool.query(`
|
||||||
|
SELECT add_continuous_aggregate_policy('checks_hourly',
|
||||||
|
start_offset => INTERVAL '3 hours',
|
||||||
|
end_offset => INTERVAL '1 hour',
|
||||||
|
schedule_interval => INTERVAL '1 hour',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`);
|
||||||
|
|
||||||
|
// Uptime & Response Time (Daily)
|
||||||
|
await pool.query(`
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
monitor_id,
|
||||||
|
time_bucket('1 day', created_at) AS bucket,
|
||||||
|
COUNT(*) AS total_checks,
|
||||||
|
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
|
||||||
|
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
|
||||||
|
AVG(response_time) AS avg_response_time,
|
||||||
|
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
|
||||||
|
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
|
||||||
|
MAX(response_time) AS max_response_time
|
||||||
|
FROM checks
|
||||||
|
GROUP BY monitor_id, bucket
|
||||||
|
WITH NO DATA;
|
||||||
|
`);
|
||||||
|
await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`);
|
||||||
|
await pool.query(`
|
||||||
|
SELECT add_continuous_aggregate_policy('checks_daily',
|
||||||
|
start_offset => INTERVAL '3 days',
|
||||||
|
end_offset => INTERVAL '1 day',
|
||||||
|
schedule_interval => INTERVAL '1 day',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`);
|
||||||
|
|
||||||
|
// Infrastructure Metrics (Hourly)
|
||||||
|
await pool.query(`
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
monitor_id,
|
||||||
|
time_bucket('1 hour', created_at) AS bucket,
|
||||||
|
AVG(cpu_usage_percent) AS avg_cpu,
|
||||||
|
MAX(cpu_usage_percent) AS max_cpu,
|
||||||
|
AVG(mem_usage_percent) AS avg_memory,
|
||||||
|
MAX(mem_usage_percent) AS max_memory,
|
||||||
|
COUNT(*) AS sample_count
|
||||||
|
FROM checks
|
||||||
|
WHERE monitor_type = 'hardware'
|
||||||
|
GROUP BY monitor_id, bucket
|
||||||
|
WITH NO DATA;
|
||||||
|
`);
|
||||||
|
await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`);
|
||||||
|
await pool.query(`
|
||||||
|
SELECT add_continuous_aggregate_policy('hardware_hourly',
|
||||||
|
start_offset => INTERVAL '3 hours',
|
||||||
|
end_offset => INTERVAL '1 hour',
|
||||||
|
schedule_interval => INTERVAL '1 hour',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`);
|
||||||
|
|
||||||
|
// PageSpeed Scores (Daily)
|
||||||
|
await pool.query(`
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
monitor_id,
|
||||||
|
time_bucket('1 day', created_at) AS bucket,
|
||||||
|
AVG(lighthouse_performance) AS avg_performance,
|
||||||
|
AVG(lighthouse_accessibility) AS avg_accessibility,
|
||||||
|
AVG(lighthouse_best_practices) AS avg_best_practices,
|
||||||
|
AVG(lighthouse_seo) AS avg_seo,
|
||||||
|
COUNT(*) AS sample_count
|
||||||
|
FROM checks
|
||||||
|
WHERE monitor_type = 'pagespeed'
|
||||||
|
GROUP BY monitor_id, bucket
|
||||||
|
WITH NO DATA;
|
||||||
|
`);
|
||||||
|
await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`);
|
||||||
|
await pool.query(`
|
||||||
|
SELECT add_continuous_aggregate_policy('pagespeed_daily',
|
||||||
|
start_offset => INTERVAL '3 days',
|
||||||
|
end_offset => INTERVAL '1 day',
|
||||||
|
schedule_interval => INTERVAL '1 day',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropContinuousAggregates = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE;
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE;
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE;
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const createRetentionCompression = async (pool: Pool) => {
|
||||||
|
// -- Compression policies --
|
||||||
|
|
||||||
|
// Checks
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE checks SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'monitor_id',
|
||||||
|
timescaledb.compress_orderby = 'created_at DESC'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Check child tables
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE check_disks SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'check_id',
|
||||||
|
timescaledb.compress_orderby = 'check_created_at DESC'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE check_network_interfaces SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'check_id',
|
||||||
|
timescaledb.compress_orderby = 'check_created_at DESC'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE check_errors SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'check_id',
|
||||||
|
timescaledb.compress_orderby = 'check_created_at DESC'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Geo checks
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE geo_checks SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'monitor_id',
|
||||||
|
timescaledb.compress_orderby = 'created_at DESC'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE geo_check_results SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'geo_check_id',
|
||||||
|
timescaledb.compress_orderby = 'geo_check_created_at DESC'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// -- Retention policies (default 30 days) --
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// -- Dynamic retention helper function --
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
ttl_interval INTERVAL;
|
||||||
|
BEGIN
|
||||||
|
ttl_interval := (new_ttl_days || ' days')::INTERVAL;
|
||||||
|
|
||||||
|
PERFORM remove_retention_policy('checks', if_exists => true);
|
||||||
|
PERFORM remove_retention_policy('check_disks', if_exists => true);
|
||||||
|
PERFORM remove_retention_policy('check_network_interfaces', if_exists => true);
|
||||||
|
PERFORM remove_retention_policy('check_errors', if_exists => true);
|
||||||
|
PERFORM remove_retention_policy('geo_checks', if_exists => true);
|
||||||
|
PERFORM remove_retention_policy('geo_check_results', if_exists => true);
|
||||||
|
|
||||||
|
PERFORM add_retention_policy('checks', ttl_interval);
|
||||||
|
PERFORM add_retention_policy('check_disks', ttl_interval);
|
||||||
|
PERFORM add_retention_policy('check_network_interfaces', ttl_interval);
|
||||||
|
PERFORM add_retention_policy('check_errors', ttl_interval);
|
||||||
|
PERFORM add_retention_policy('geo_checks', ttl_interval);
|
||||||
|
PERFORM add_retention_policy('geo_check_results', ttl_interval);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropRetentionCompression = async (pool: Pool) => {
|
||||||
|
// Remove retention policies
|
||||||
|
await pool.query(`
|
||||||
|
SELECT remove_retention_policy('checks', if_exists => true);
|
||||||
|
SELECT remove_retention_policy('check_disks', if_exists => true);
|
||||||
|
SELECT remove_retention_policy('check_network_interfaces', if_exists => true);
|
||||||
|
SELECT remove_retention_policy('check_errors', if_exists => true);
|
||||||
|
SELECT remove_retention_policy('geo_checks', if_exists => true);
|
||||||
|
SELECT remove_retention_policy('geo_check_results', if_exists => true);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Remove compression policies
|
||||||
|
await pool.query(`
|
||||||
|
SELECT remove_compression_policy('checks', if_exists => true);
|
||||||
|
SELECT remove_compression_policy('check_disks', if_exists => true);
|
||||||
|
SELECT remove_compression_policy('check_network_interfaces', if_exists => true);
|
||||||
|
SELECT remove_compression_policy('check_errors', if_exists => true);
|
||||||
|
SELECT remove_compression_policy('geo_checks', if_exists => true);
|
||||||
|
SELECT remove_compression_policy('geo_check_results', if_exists => true);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Disable compression on tables
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE checks SET (timescaledb.compress = false);
|
||||||
|
ALTER TABLE check_disks SET (timescaledb.compress = false);
|
||||||
|
ALTER TABLE check_network_interfaces SET (timescaledb.compress = false);
|
||||||
|
ALTER TABLE check_errors SET (timescaledb.compress = false);
|
||||||
|
ALTER TABLE geo_checks SET (timescaledb.compress = false);
|
||||||
|
ALTER TABLE geo_check_results SET (timescaledb.compress = false);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop dynamic retention function
|
||||||
|
await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
export const addStrategyToMonitors = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE monitors
|
||||||
|
ADD COLUMN IF NOT EXISTS strategy TEXT DEFAULT NULL;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dropStrategyFromMonitors = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE monitors
|
||||||
|
DROP COLUMN IF EXISTS strategy;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
|
import { createEnums, dropEnums } from "./0001_create_enums.js";
|
||||||
|
import { createTeams, dropTeams } from "./0002_create_teams.js";
|
||||||
|
import { createUsers, dropUsers } from "./0003_create_users.js";
|
||||||
|
import { createMonitors, dropMonitors } from "./0004_create_monitors.js";
|
||||||
|
import { createNotifications, dropNotifications } from "./0005_create_notifications.js";
|
||||||
|
import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js";
|
||||||
|
import { createIncidents, dropIncidents } from "./0007_create_incidents.js";
|
||||||
|
import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js";
|
||||||
|
import { createChecks, dropChecks } from "./0009_create_checks.js";
|
||||||
|
import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js";
|
||||||
|
import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js";
|
||||||
|
import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js";
|
||||||
|
import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js";
|
||||||
|
import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js";
|
||||||
|
import { createInvites, dropInvites } from "./0015_create_invites.js";
|
||||||
|
import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js";
|
||||||
|
import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js";
|
||||||
|
import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js";
|
||||||
|
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
|
||||||
|
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
|
||||||
|
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
|
||||||
|
import { addStrategyToMonitors, dropStrategyFromMonitors } from "./0022_add_strategy_to_monitors.js";
|
||||||
|
|
||||||
|
const SERVICE_NAME = "TimescaleDB Migrations";
|
||||||
|
|
||||||
|
type MigrationEntry = {
|
||||||
|
name: string;
|
||||||
|
up: (pool: Pool) => Promise<void>;
|
||||||
|
down: (pool: Pool) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrations: MigrationEntry[] = [
|
||||||
|
{ name: "0001_create_enums", up: createEnums, down: dropEnums },
|
||||||
|
{ name: "0002_create_teams", up: createTeams, down: dropTeams },
|
||||||
|
{ name: "0003_create_users", up: createUsers, down: dropUsers },
|
||||||
|
{ name: "0004_create_monitors", up: createMonitors, down: dropMonitors },
|
||||||
|
{ name: "0005_create_notifications", up: createNotifications, down: dropNotifications },
|
||||||
|
{ name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications },
|
||||||
|
{ name: "0007_create_incidents", up: createIncidents, down: dropIncidents },
|
||||||
|
{ name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats },
|
||||||
|
{ name: "0009_create_checks", up: createChecks, down: dropChecks },
|
||||||
|
{ name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks },
|
||||||
|
{ name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces },
|
||||||
|
{ name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors },
|
||||||
|
{ name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks },
|
||||||
|
{ name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults },
|
||||||
|
{ name: "0015_create_invites", up: createInvites, down: dropInvites },
|
||||||
|
{ name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens },
|
||||||
|
{ name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows },
|
||||||
|
{ name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages },
|
||||||
|
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
|
||||||
|
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
|
||||||
|
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
|
||||||
|
{ name: "0022_add_strategy_to_monitors", up: addStrategyToMonitors, down: dropStrategyFromMonitors },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ensureMigrationsTable = async (pool: Pool) => {
|
||||||
|
await pool.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE migration_status AS ENUM ('completed', 'failed');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
status migration_status NOT NULL,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => {
|
||||||
|
try {
|
||||||
|
logger?.info({ message: "Running migrations", service: SERVICE_NAME });
|
||||||
|
|
||||||
|
await ensureMigrationsTable(pool);
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]);
|
||||||
|
if (result.rowCount !== null && result.rowCount > 0) {
|
||||||
|
logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migration.up(pool);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO migrations (name, status, completed_at)
|
||||||
|
VALUES ($1, 'completed', NOW())
|
||||||
|
ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`,
|
||||||
|
[migration.name]
|
||||||
|
);
|
||||||
|
logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME });
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO migrations (name, status, error)
|
||||||
|
VALUES ($1, 'failed', $2)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`,
|
||||||
|
[migration.name, err?.message]
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.info({ message: "Migrations completed", service: SERVICE_NAME });
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => {
|
||||||
|
try {
|
||||||
|
await ensureMigrationsTable(pool);
|
||||||
|
|
||||||
|
// Find the last completed migration
|
||||||
|
const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1");
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMigrationName = result.rows[0].name;
|
||||||
|
const migration = migrations.find((m) => m.name === lastMigrationName);
|
||||||
|
if (!migration) {
|
||||||
|
throw new Error(`Migration "${lastMigrationName}" not found in migration list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME });
|
||||||
|
await migration.down(pool);
|
||||||
|
await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]);
|
||||||
|
logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME });
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { runTimescaleDBMigrations, rollbackTimescaleDBMigration };
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Schema, model, type Types } from "mongoose";
|
||||||
|
import { DLQItemTypes, DLQItemStatuses, type DLQItem } from "@/types/dlqItem.js";
|
||||||
|
|
||||||
|
export interface DLQItemDocument extends Omit<DLQItem, "id" | "monitorId" | "teamId" | "nextRetryAt" | "createdAt" | "updatedAt"> {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
monitorId: Types.ObjectId;
|
||||||
|
teamId: Types.ObjectId;
|
||||||
|
nextRetryAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DLQItemSchema = new Schema<DLQItemDocument>(
|
||||||
|
{
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: DLQItemTypes,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: DLQItemStatuses,
|
||||||
|
default: "pending",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: Schema.Types.Mixed,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
monitorId: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: "Monitor",
|
||||||
|
required: true,
|
||||||
|
immutable: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: "Team",
|
||||||
|
required: true,
|
||||||
|
immutable: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
retryCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
maxRetries: {
|
||||||
|
type: Number,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
lastError: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
nextRetryAt: {
|
||||||
|
type: Date,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
DLQItemSchema.index({ status: 1, nextRetryAt: 1 });
|
||||||
|
DLQItemSchema.index({ teamId: 1, status: 1 });
|
||||||
|
DLQItemSchema.index({ createdAt: 1 });
|
||||||
|
|
||||||
|
const DLQItemModel = model<DLQItemDocument>("DLQItem", DLQItemSchema);
|
||||||
|
|
||||||
|
export { DLQItemModel };
|
||||||
|
export default DLQItemModel;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Schema, model, Types } from "mongoose";
|
import { Schema, model, Types } from "mongoose";
|
||||||
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
|
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
|
||||||
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
|
import { MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js";
|
||||||
import type {
|
import type {
|
||||||
CheckAudits,
|
CheckAudits,
|
||||||
CheckCaptureInfo,
|
CheckCaptureInfo,
|
||||||
@@ -330,6 +330,10 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
|||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
strategy: {
|
||||||
|
type: String,
|
||||||
|
enum: PageSpeedStrategies,
|
||||||
|
},
|
||||||
group: {
|
group: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
|||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
|
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
notificationName: {
|
notificationName: {
|
||||||
|
|||||||
@@ -36,3 +36,6 @@ export { default as MaintenanceWindowModel } from "@/db/models/MaintenanceWindow
|
|||||||
|
|
||||||
export * from "@/db/models/GeoCheck.js";
|
export * from "@/db/models/GeoCheck.js";
|
||||||
export { default as GeoCheckModel } from "@/db/models/GeoCheck.js";
|
export { default as GeoCheckModel } from "@/db/models/GeoCheck.js";
|
||||||
|
|
||||||
|
export * from "@/db/models/DLQItem.js";
|
||||||
|
export { default as DLQItemModel } from "@/db/models/DLQItem.js";
|
||||||
+6
-12
@@ -1,16 +1,14 @@
|
|||||||
import { initializeServices } from "./config/services.js";
|
import { initializeServices } from "./config/services.js";
|
||||||
import { initializeControllers } from "./config/controllers.js";
|
import { initializeControllers } from "./config/controllers.js";
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { initShutdownListener } from "./shutdown.js";
|
import { initShutdownListener } from "@/shutdown.js";
|
||||||
import { validateEnv } from "./validation/envValidation.js";
|
import { validateEnv } from "@/validation/envValidation.js";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { runMigrations } from "./db/migration/index.js";
|
|
||||||
|
|
||||||
import Logger, { ILogger } from "@/utils/logger.js";
|
import Logger, { ILogger } from "@/utils/logger.js";
|
||||||
import { SettingsService } from "@/service/index.js";
|
import { SettingsService } from "@/service/index.js";
|
||||||
import { MongoSettingsRepository } from "./repositories/index.js";
|
|
||||||
|
|
||||||
const SERVICE_NAME = "Server";
|
const SERVICE_NAME = "Server";
|
||||||
let logger: ILogger;
|
let logger: ILogger;
|
||||||
@@ -25,19 +23,15 @@ const startApp = async () => {
|
|||||||
const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8"));
|
const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8"));
|
||||||
const frontendPath = path.join(__dirname, "..", "public");
|
const frontendPath = path.join(__dirname, "..", "public");
|
||||||
|
|
||||||
// Create services
|
// Create settings service (env only — DB repository injected after connect)
|
||||||
const settingsRepository = new MongoSettingsRepository();
|
const settingsService = new SettingsService(env);
|
||||||
const settingsService = new SettingsService(settingsRepository, env);
|
|
||||||
|
|
||||||
const envSettings = settingsService.loadSettings();
|
const envSettings = settingsService.loadSettings();
|
||||||
|
|
||||||
// Create logger
|
// Create logger
|
||||||
logger = new Logger({ envSettings });
|
logger = new Logger({ envSettings });
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services (connects DB, creates repositories, injects settingsRepository)
|
||||||
const services = await initializeServices({ logger, envSettings, settingsService, settingsRepository });
|
const services = await initializeServices({ logger, envSettings, settingsService });
|
||||||
|
|
||||||
await runMigrations(logger);
|
|
||||||
|
|
||||||
// Initialize controllers
|
// Initialize controllers
|
||||||
const controllers = initializeControllers(services);
|
const controllers = initializeControllers(services);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { AppError } from "@/utils/AppError.js";
|
|||||||
|
|
||||||
const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
|
const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
|
||||||
const status = error instanceof AppError ? error.status || 500 : 500;
|
const status = error instanceof AppError ? error.status || 500 : 500;
|
||||||
const message = error instanceof AppError ? error.message : "Server error";
|
const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error";
|
||||||
const service = error instanceof AppError ? error.service : "unknownService";
|
const service = error instanceof AppError ? error.service : "unknownService";
|
||||||
const method = error instanceof AppError ? error.method : "unknownMethod";
|
const method = error instanceof AppError ? error.method : "unknownMethod";
|
||||||
logger.error({
|
logger.error({
|
||||||
message: message,
|
message: message,
|
||||||
service: service,
|
service: service,
|
||||||
method: method,
|
method: method,
|
||||||
stack: error instanceof AppError ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
details: error instanceof AppError ? error.details : undefined,
|
details: error instanceof AppError ? error.details : undefined,
|
||||||
});
|
});
|
||||||
res.status(status).json({
|
res.status(status).json({
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
|||||||
|
import type { DLQItem, DLQItemType, DLQItemStatus } from "@/types/index.js";
|
||||||
|
|
||||||
|
export interface DLQQueryFilters {
|
||||||
|
type?: DLQItemType;
|
||||||
|
status?: DLQItemStatus;
|
||||||
|
page: number;
|
||||||
|
rowsPerPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DLQStatusCount {
|
||||||
|
status: DLQItemStatus;
|
||||||
|
type: DLQItemType;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDLQRepository {
|
||||||
|
// create
|
||||||
|
create(item: Partial<DLQItem>): Promise<DLQItem>;
|
||||||
|
// fetch
|
||||||
|
findById(id: string): Promise<DLQItem | null>;
|
||||||
|
findByTeamId(teamId: string, filters: DLQQueryFilters): Promise<DLQItem[]>;
|
||||||
|
findRetryable(limit: number): Promise<DLQItem[]>;
|
||||||
|
countByTeamId(teamId: string): Promise<number>;
|
||||||
|
countByTeamIdGrouped(teamId: string): Promise<DLQStatusCount[]>;
|
||||||
|
// update
|
||||||
|
updateById(id: string, patch: Partial<DLQItem>): Promise<DLQItem>;
|
||||||
|
// delete
|
||||||
|
deleteById(id: string, teamId: string): Promise<number>;
|
||||||
|
deleteOlderThan(date: Date): Promise<number>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { DLQItemModel } from "@/db/models/index.js";
|
||||||
|
import type { DLQItemDocument } from "@/db/models/DLQItem.js";
|
||||||
|
import type { DLQItem } from "@/types/index.js";
|
||||||
|
import type { IDLQRepository, DLQQueryFilters, DLQStatusCount } from "@/repositories/dlq/IDLQRepository.js";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
class MongoDLQRepository implements IDLQRepository {
|
||||||
|
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
private toDateString = (value?: Date | string | null): string => {
|
||||||
|
if (!value) {
|
||||||
|
return new Date(0).toISOString();
|
||||||
|
}
|
||||||
|
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
protected toEntity = (doc: DLQItemDocument): DLQItem => {
|
||||||
|
return {
|
||||||
|
id: this.toStringId(doc._id),
|
||||||
|
type: doc.type,
|
||||||
|
status: doc.status,
|
||||||
|
payload: doc.payload,
|
||||||
|
monitorId: this.toStringId(doc.monitorId),
|
||||||
|
teamId: this.toStringId(doc.teamId),
|
||||||
|
retryCount: doc.retryCount,
|
||||||
|
maxRetries: doc.maxRetries,
|
||||||
|
lastError: doc.lastError,
|
||||||
|
nextRetryAt: this.toDateString(doc.nextRetryAt),
|
||||||
|
createdAt: this.toDateString(doc.createdAt),
|
||||||
|
updatedAt: this.toDateString(doc.updatedAt),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
protected mapDocuments = (documents: DLQItemDocument[] | DLQItemDocument | null): DLQItem[] => {
|
||||||
|
if (!documents) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(documents)) {
|
||||||
|
return documents.map((doc) => this.toEntity(doc));
|
||||||
|
}
|
||||||
|
return [this.toEntity(documents)];
|
||||||
|
};
|
||||||
|
|
||||||
|
async create(item: Partial<DLQItem>): Promise<DLQItem> {
|
||||||
|
const newItem = await DLQItemModel.create(item);
|
||||||
|
return this.toEntity(newItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
findById = async (id: string): Promise<DLQItem | null> => {
|
||||||
|
const item = await DLQItemModel.findById(new mongoose.Types.ObjectId(id));
|
||||||
|
return item ? this.toEntity(item) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
findByTeamId = async (teamId: string, filters: DLQQueryFilters): Promise<DLQItem[]> => {
|
||||||
|
const query: Record<string, unknown> = {
|
||||||
|
teamId: new mongoose.Types.ObjectId(teamId),
|
||||||
|
};
|
||||||
|
if (filters.type) {
|
||||||
|
query.type = filters.type;
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
query.status = filters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await DLQItemModel.find(query)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(filters.page * filters.rowsPerPage)
|
||||||
|
.limit(filters.rowsPerPage);
|
||||||
|
return this.mapDocuments(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
findRetryable = async (limit: number): Promise<DLQItem[]> => {
|
||||||
|
const items = await DLQItemModel.find({
|
||||||
|
status: { $in: ["pending", "retrying"] },
|
||||||
|
nextRetryAt: { $lte: new Date() },
|
||||||
|
})
|
||||||
|
.sort({ nextRetryAt: 1 })
|
||||||
|
.limit(limit);
|
||||||
|
return this.mapDocuments(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
countByTeamId = async (teamId: string): Promise<number> => {
|
||||||
|
return DLQItemModel.countDocuments({
|
||||||
|
teamId: new mongoose.Types.ObjectId(teamId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
countByTeamIdGrouped = async (teamId: string): Promise<DLQStatusCount[]> => {
|
||||||
|
const results = await DLQItemModel.aggregate([
|
||||||
|
{ $match: { teamId: new mongoose.Types.ObjectId(teamId) } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { status: "$status", type: "$type" },
|
||||||
|
count: { $sum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return results.map((r) => ({
|
||||||
|
status: r._id.status,
|
||||||
|
type: r._id.type,
|
||||||
|
count: r.count,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateById = async (id: string, patch: Partial<DLQItem>): Promise<DLQItem> => {
|
||||||
|
const updated = await DLQItemModel.findOneAndUpdate(
|
||||||
|
{ _id: new mongoose.Types.ObjectId(id) },
|
||||||
|
{ $set: { ...patch } },
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
throw new AppError({ message: `DLQ item with id ${id} not found`, status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteById = async (id: string, teamId: string): Promise<number> => {
|
||||||
|
const result = await DLQItemModel.deleteOne({
|
||||||
|
_id: new mongoose.Types.ObjectId(id),
|
||||||
|
teamId: new mongoose.Types.ObjectId(teamId),
|
||||||
|
});
|
||||||
|
return result.deletedCount || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteOlderThan = async (date: Date): Promise<number> => {
|
||||||
|
const result = await DLQItemModel.deleteMany({
|
||||||
|
createdAt: { $lt: date },
|
||||||
|
});
|
||||||
|
return result.deletedCount || 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MongoDLQRepository;
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { IGeoChecksRepository, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
|
||||||
|
import type { GeoCheck, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck, MonitorType } from "@/types/index.js";
|
||||||
|
import { getDateForRange } from "@/utils/dataUtils.js";
|
||||||
|
|
||||||
|
const dateStringToBucket = (dateString: string): string => {
|
||||||
|
if (dateString.includes("%M")) return "1 minute";
|
||||||
|
if (dateString.includes("%H")) return "1 hour";
|
||||||
|
return "1 day";
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TimescaleGeoChecksRepository implements IGeoChecksRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
|
||||||
|
const created: GeoCheck[] = [];
|
||||||
|
|
||||||
|
for (const geoCheck of geoChecks) {
|
||||||
|
const geoResult = await this.pool.query(
|
||||||
|
`INSERT INTO geo_checks (monitor_id, team_id, monitor_type, expiry)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at`,
|
||||||
|
[geoCheck.metadata.monitorId, geoCheck.metadata.teamId, geoCheck.metadata.type, geoCheck.expiry ? new Date(geoCheck.expiry) : null]
|
||||||
|
);
|
||||||
|
const row = geoResult.rows[0];
|
||||||
|
if (!row) continue;
|
||||||
|
|
||||||
|
for (const result of geoCheck.results) {
|
||||||
|
await this.pool.query(
|
||||||
|
`INSERT INTO geo_check_results (
|
||||||
|
geo_check_id, geo_check_created_at,
|
||||||
|
status, status_code,
|
||||||
|
location_continent, location_region, location_country, location_state, location_city,
|
||||||
|
location_longitude, location_latitude,
|
||||||
|
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
|
||||||
|
[
|
||||||
|
row.id,
|
||||||
|
row.created_at,
|
||||||
|
result.status,
|
||||||
|
result.statusCode,
|
||||||
|
result.location.continent,
|
||||||
|
result.location.region,
|
||||||
|
result.location.country,
|
||||||
|
result.location.state,
|
||||||
|
result.location.city,
|
||||||
|
result.location.longitude,
|
||||||
|
result.location.latitude,
|
||||||
|
result.timings.total,
|
||||||
|
result.timings.dns,
|
||||||
|
result.timings.tcp,
|
||||||
|
result.timings.tls,
|
||||||
|
result.timings.firstByte,
|
||||||
|
result.timings.download,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
created.push(this.toGeoCheck(row, geoCheck.results));
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
findByMonitorId = async (
|
||||||
|
monitorId: string,
|
||||||
|
sortOrder: string,
|
||||||
|
dateRange: string,
|
||||||
|
page: number,
|
||||||
|
rowsPerPage: number,
|
||||||
|
continents?: GeoContinent[]
|
||||||
|
): Promise<FlatGeoChecksQueryResult> => {
|
||||||
|
const conditions: string[] = ["gc.monitor_id = $1"];
|
||||||
|
const values: unknown[] = [monitorId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
const rangeDate = getDateForRange(dateRange);
|
||||||
|
if (rangeDate) {
|
||||||
|
conditions.push(`gc.created_at >= $${paramIndex++}`);
|
||||||
|
values.push(rangeDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (continents && continents.length > 0) {
|
||||||
|
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
|
||||||
|
values.push(continents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.join(" AND ");
|
||||||
|
const direction = sortOrder === "asc" ? "ASC" : "DESC";
|
||||||
|
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
|
||||||
|
|
||||||
|
const countValues = [...values];
|
||||||
|
const dataValues = [...values, rowsPerPage, offset];
|
||||||
|
|
||||||
|
const [countResult, dataResult] = await Promise.all([
|
||||||
|
this.pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM geo_checks gc
|
||||||
|
JOIN geo_check_results r ON r.geo_check_id = gc.id
|
||||||
|
WHERE ${where}`,
|
||||||
|
countValues
|
||||||
|
),
|
||||||
|
this.pool.query(
|
||||||
|
`SELECT gc.id, gc.monitor_id, gc.team_id, gc.monitor_type,
|
||||||
|
r.location_continent, r.location_region, r.location_country, r.location_state, r.location_city,
|
||||||
|
r.location_longitude, r.location_latitude,
|
||||||
|
r.status, r.status_code,
|
||||||
|
r.timing_total, r.timing_dns, r.timing_tcp, r.timing_tls, r.timing_first_byte, r.timing_download,
|
||||||
|
gc.created_at, gc.updated_at
|
||||||
|
FROM geo_checks gc
|
||||||
|
JOIN geo_check_results r ON r.geo_check_id = gc.id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY gc.created_at ${direction}
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||||
|
dataValues
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const geoChecks: FlatGeoCheck[] = dataResult.rows.map((row) => ({
|
||||||
|
id: `${row.monitor_id}-${new Date(row.created_at).getTime()}-${row.location_continent}-${row.location_city}-${row.id.substring(0, 8)}`,
|
||||||
|
monitorId: row.monitor_id,
|
||||||
|
teamId: row.team_id,
|
||||||
|
type: row.monitor_type,
|
||||||
|
location: {
|
||||||
|
continent: row.location_continent,
|
||||||
|
region: row.location_region,
|
||||||
|
country: row.location_country,
|
||||||
|
state: row.location_state,
|
||||||
|
city: row.location_city,
|
||||||
|
longitude: row.location_longitude,
|
||||||
|
latitude: row.location_latitude,
|
||||||
|
},
|
||||||
|
status: row.status,
|
||||||
|
statusCode: row.status_code,
|
||||||
|
timings: {
|
||||||
|
total: row.timing_total ?? 0,
|
||||||
|
dns: row.timing_dns ?? 0,
|
||||||
|
tcp: row.timing_tcp ?? 0,
|
||||||
|
tls: row.timing_tls ?? 0,
|
||||||
|
firstByte: row.timing_first_byte ?? 0,
|
||||||
|
download: row.timing_download ?? 0,
|
||||||
|
},
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { geoChecksCount: countResult.rows[0].count, geoChecks };
|
||||||
|
};
|
||||||
|
|
||||||
|
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
|
||||||
|
const geoRows = await this.pool.query(
|
||||||
|
`SELECT id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at
|
||||||
|
FROM geo_checks
|
||||||
|
WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[monitorId, startDate, endDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const geoChecks: GeoCheck[] = [];
|
||||||
|
for (const row of geoRows.rows) {
|
||||||
|
const resultsRows = await this.pool.query(
|
||||||
|
`SELECT status, status_code,
|
||||||
|
location_continent, location_region, location_country, location_state, location_city,
|
||||||
|
location_longitude, location_latitude,
|
||||||
|
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
|
||||||
|
FROM geo_check_results
|
||||||
|
WHERE geo_check_id = $1`,
|
||||||
|
[row.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = resultsRows.rows.map(this.toGeoCheckResult);
|
||||||
|
geoChecks.push(this.toGeoCheck(row, results));
|
||||||
|
}
|
||||||
|
|
||||||
|
return geoChecks;
|
||||||
|
};
|
||||||
|
|
||||||
|
findGroupedByMonitorIdAndDateRange = async (
|
||||||
|
monitorId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
dateFormat: string,
|
||||||
|
continents?: GeoContinent[]
|
||||||
|
): Promise<GroupedGeoCheck[]> => {
|
||||||
|
const bucket = dateStringToBucket(dateFormat);
|
||||||
|
|
||||||
|
const conditions: string[] = ["gc.monitor_id = $2", "gc.created_at >= $3", "gc.created_at <= $4"];
|
||||||
|
const values: unknown[] = [bucket, monitorId, startDate, endDate];
|
||||||
|
let paramIndex = 5;
|
||||||
|
|
||||||
|
if (continents && continents.length > 0) {
|
||||||
|
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
|
||||||
|
values.push(continents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pool.query(
|
||||||
|
`SELECT
|
||||||
|
time_bucket($1::interval, gc.created_at) AS bucket_date,
|
||||||
|
r.location_continent AS continent,
|
||||||
|
ROUND(AVG(r.timing_total)::numeric, 2) AS "avgResponseTime",
|
||||||
|
COUNT(*)::int AS "totalChecks",
|
||||||
|
ROUND((100.0 * COUNT(*) FILTER (WHERE r.status = TRUE) / COUNT(*))::numeric, 2) AS "uptimePercentage"
|
||||||
|
FROM geo_checks gc
|
||||||
|
JOIN geo_check_results r ON r.geo_check_id = gc.id
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
GROUP BY bucket_date, r.location_continent
|
||||||
|
ORDER BY bucket_date, r.location_continent`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
bucketDate: (row.bucket_date as Date).toISOString(),
|
||||||
|
continent: row.continent as GeoContinent,
|
||||||
|
avgResponseTime: Number(row.avgResponseTime ?? 0),
|
||||||
|
totalChecks: Number(row.totalChecks ?? 0),
|
||||||
|
uptimePercentage: Number(row.uptimePercentage ?? 0),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteByMonitorId = async (monitorId: string): Promise<number> => {
|
||||||
|
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id = $1`, [monitorId]);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteByTeamId = async (teamId: string): Promise<number> => {
|
||||||
|
const result = await this.pool.query(`DELETE FROM geo_checks WHERE team_id = $1`, [teamId]);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
|
||||||
|
if (!monitorIds.length) {
|
||||||
|
const result = await this.pool.query(`DELETE FROM geo_checks`);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
}
|
||||||
|
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id != ALL($1)`, [monitorIds]);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
private toGeoCheckResult = (row: Record<string, unknown>): GeoCheckResult => ({
|
||||||
|
location: {
|
||||||
|
continent: (row.location_continent as GeoContinent) ?? "",
|
||||||
|
region: (row.location_region as string) ?? "",
|
||||||
|
country: (row.location_country as string) ?? "",
|
||||||
|
state: (row.location_state as string) ?? "",
|
||||||
|
city: (row.location_city as string) ?? "",
|
||||||
|
longitude: (row.location_longitude as number) ?? 0,
|
||||||
|
latitude: (row.location_latitude as number) ?? 0,
|
||||||
|
},
|
||||||
|
status: (row.status as boolean) ?? false,
|
||||||
|
statusCode: (row.status_code as number) ?? 0,
|
||||||
|
timings: {
|
||||||
|
total: (row.timing_total as number) ?? 0,
|
||||||
|
dns: (row.timing_dns as number) ?? 0,
|
||||||
|
tcp: (row.timing_tcp as number) ?? 0,
|
||||||
|
tls: (row.timing_tls as number) ?? 0,
|
||||||
|
firstByte: (row.timing_first_byte as number) ?? 0,
|
||||||
|
download: (row.timing_download as number) ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
private toGeoCheck = (row: Record<string, unknown>, results: GeoCheckResult[]): GeoCheck => ({
|
||||||
|
id: row.id as string,
|
||||||
|
metadata: {
|
||||||
|
monitorId: row.monitor_id as string,
|
||||||
|
teamId: row.team_id as string,
|
||||||
|
type: row.monitor_type as MonitorType,
|
||||||
|
},
|
||||||
|
results,
|
||||||
|
expiry: row.expiry ? (row.expiry as Date).toISOString() : new Date(0).toISOString(),
|
||||||
|
__v: 0,
|
||||||
|
createdAt: (row.created_at as Date).toISOString(),
|
||||||
|
updatedAt: (row.updated_at as Date).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
+2
-2
@@ -5,7 +5,7 @@ import type { IIncidentsRepository } from "@/repositories/index.js";
|
|||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
class MongoIncidentRepository implements IIncidentsRepository {
|
class MongoIncidentsRepository implements IIncidentsRepository {
|
||||||
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
|
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
@@ -288,4 +288,4 @@ class MongoIncidentRepository implements IIncidentsRepository {
|
|||||||
return result.deletedCount ?? 0;
|
return result.deletedCount ?? 0;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export default MongoIncidentRepository;
|
export default MongoIncidentsRepository;
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { Incident, IncidentSummary, IncidentResolutionType } from "@/types/incident.js";
|
||||||
|
import type { IIncidentsRepository } from "@/repositories/incidents/IIncidentsRepository.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
interface IncidentRow {
|
||||||
|
id: string;
|
||||||
|
monitor_id: string;
|
||||||
|
team_id: string;
|
||||||
|
start_time: Date;
|
||||||
|
end_time: Date | null;
|
||||||
|
status: boolean;
|
||||||
|
message: string | null;
|
||||||
|
status_code: number | null;
|
||||||
|
resolution_type: IncidentResolutionType;
|
||||||
|
resolved_by: string | null;
|
||||||
|
resolved_by_email: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = `id, monitor_id, team_id, start_time, end_time, status, message, status_code,
|
||||||
|
resolution_type, resolved_by, resolved_by_email, comment, created_at, updated_at`;
|
||||||
|
|
||||||
|
export class TimescaleIncidentsRepository implements IIncidentsRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (incident: Partial<Incident>): Promise<Incident> => {
|
||||||
|
const result = await this.pool.query<IncidentRow>(
|
||||||
|
`INSERT INTO incidents (monitor_id, team_id, start_time, end_time, status, message, status_code, resolution_type, resolved_by, resolved_by_email, comment)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[
|
||||||
|
incident.monitorId,
|
||||||
|
incident.teamId,
|
||||||
|
incident.startTime ? new Date(Number(incident.startTime) || incident.startTime) : new Date(),
|
||||||
|
incident.endTime ? new Date(Number(incident.endTime) || incident.endTime) : null,
|
||||||
|
incident.status ?? true,
|
||||||
|
incident.message ?? null,
|
||||||
|
incident.statusCode ?? null,
|
||||||
|
incident.resolutionType ?? null,
|
||||||
|
incident.resolvedBy ?? null,
|
||||||
|
incident.resolvedByEmail ?? null,
|
||||||
|
incident.comment ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Failed to create incident", status: 500 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findById = async (incidentId: string, teamId: string): Promise<Incident> => {
|
||||||
|
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2`, [incidentId, teamId]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: `Incident with id ${incidentId} not found`, status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findActiveByIncidentId = async (incidentId: string, teamId: string): Promise<Incident | null> => {
|
||||||
|
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2 AND status = TRUE`, [
|
||||||
|
incidentId,
|
||||||
|
teamId,
|
||||||
|
]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
return row ? this.toEntity(row) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
findActiveByMonitorId = async (monitorId: string, teamId: string): Promise<Incident | null> => {
|
||||||
|
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE monitor_id = $1 AND team_id = $2 AND status = TRUE`, [
|
||||||
|
monitorId,
|
||||||
|
teamId,
|
||||||
|
]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
return row ? this.toEntity(row) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
findByTeamId = async (
|
||||||
|
teamId: string,
|
||||||
|
startDate: Date | undefined,
|
||||||
|
page: number,
|
||||||
|
rowsPerPage: number,
|
||||||
|
sortOrder?: string,
|
||||||
|
status?: boolean,
|
||||||
|
monitorId?: string,
|
||||||
|
resolutionType?: string
|
||||||
|
): Promise<Incident[]> => {
|
||||||
|
const { conditions, values, paramIndex } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
|
||||||
|
const direction = sortOrder === "asc" ? "ASC" : "DESC";
|
||||||
|
const offset = page * rowsPerPage;
|
||||||
|
|
||||||
|
const result = await this.pool.query<IncidentRow>(
|
||||||
|
`SELECT ${COLUMNS} FROM incidents
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY created_at ${direction}
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
[...values, rowsPerPage, offset]
|
||||||
|
);
|
||||||
|
return result.rows.map(this.toEntity);
|
||||||
|
};
|
||||||
|
|
||||||
|
countByTeamId = async (
|
||||||
|
teamId: string,
|
||||||
|
startDate: Date | undefined,
|
||||||
|
status?: boolean,
|
||||||
|
monitorId?: string,
|
||||||
|
resolutionType?: string
|
||||||
|
): Promise<number> => {
|
||||||
|
const { conditions, values } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
|
||||||
|
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM incidents WHERE ${conditions.join(" AND ")}`, values);
|
||||||
|
return result.rows[0].count;
|
||||||
|
};
|
||||||
|
|
||||||
|
findSummaryByTeamId = async (teamId: string, limit?: number): Promise<IncidentSummary> => {
|
||||||
|
// Counts by status and resolution type
|
||||||
|
const countsResult = await this.pool.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*)::int AS total,
|
||||||
|
COUNT(*) FILTER (WHERE status = TRUE)::int AS active,
|
||||||
|
COUNT(*) FILTER (WHERE resolution_type = 'manual')::int AS manual,
|
||||||
|
COUNT(*) FILTER (WHERE resolution_type = 'automatic')::int AS automatic
|
||||||
|
FROM incidents WHERE team_id = $1`,
|
||||||
|
[teamId]
|
||||||
|
);
|
||||||
|
const counts = countsResult.rows[0] ?? { total: 0, active: 0, manual: 0, automatic: 0 };
|
||||||
|
|
||||||
|
// Average resolution time
|
||||||
|
const resTimeResult = await this.pool.query(
|
||||||
|
`SELECT AVG(EXTRACT(EPOCH FROM (end_time - start_time))) AS avg_seconds
|
||||||
|
FROM incidents
|
||||||
|
WHERE team_id = $1 AND status = FALSE AND end_time IS NOT NULL`,
|
||||||
|
[teamId]
|
||||||
|
);
|
||||||
|
const avgSeconds = resTimeResult.rows[0]?.avg_seconds ?? 0;
|
||||||
|
const avgResolutionTimeHours = Math.round((avgSeconds / 3600) * 100) / 100;
|
||||||
|
|
||||||
|
// Top monitor by incident count
|
||||||
|
const topMonitorResult = await this.pool.query(
|
||||||
|
`SELECT i.monitor_id, m.name AS monitor_name, COUNT(*)::int AS count
|
||||||
|
FROM incidents i
|
||||||
|
LEFT JOIN monitors m ON m.id = i.monitor_id
|
||||||
|
WHERE i.team_id = $1
|
||||||
|
GROUP BY i.monitor_id, m.name
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[teamId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Latest incidents
|
||||||
|
const latestLimit = Math.max(1, Number.isFinite(Number(limit)) ? Number(limit) : 10);
|
||||||
|
const latestResult = await this.pool.query(
|
||||||
|
`SELECT i.id, i.monitor_id, m.name AS monitor_name, i.status, i.start_time, i.end_time,
|
||||||
|
i.resolution_type, i.message, i.status_code, i.created_at
|
||||||
|
FROM incidents i
|
||||||
|
LEFT JOIN monitors m ON m.id = i.monitor_id
|
||||||
|
WHERE i.team_id = $1
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
[teamId, latestLimit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const topRow = topMonitorResult.rows[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: counts.total,
|
||||||
|
totalActive: counts.active,
|
||||||
|
totalManualResolutions: counts.manual,
|
||||||
|
totalAutomaticResolutions: counts.automatic,
|
||||||
|
avgResolutionTimeHours,
|
||||||
|
topMonitor: topRow
|
||||||
|
? {
|
||||||
|
monitorId: topRow.monitor_id,
|
||||||
|
monitorName: topRow.monitor_name ?? null,
|
||||||
|
incidentCount: topRow.count,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
latestIncidents: latestResult.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
monitorId: row.monitor_id,
|
||||||
|
monitorName: row.monitor_name ?? null,
|
||||||
|
status: row.status,
|
||||||
|
startTime: row.start_time.toISOString(),
|
||||||
|
endTime: row.end_time ? row.end_time.toISOString() : null,
|
||||||
|
resolutionType: row.resolution_type ?? null,
|
||||||
|
message: row.message ?? null,
|
||||||
|
statusCode: row.status_code ?? null,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
updateById = async (incidentId: string, teamId: string, patch: Partial<Incident>): Promise<Incident> => {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldMap: [keyof Incident, string][] = [
|
||||||
|
["status", "status"],
|
||||||
|
["message", "message"],
|
||||||
|
["statusCode", "status_code"],
|
||||||
|
["endTime", "end_time"],
|
||||||
|
["resolutionType", "resolution_type"],
|
||||||
|
["resolvedBy", "resolved_by"],
|
||||||
|
["resolvedByEmail", "resolved_by_email"],
|
||||||
|
["comment", "comment"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, column] of fieldMap) {
|
||||||
|
if (patch[key] !== undefined) {
|
||||||
|
const value = key === "endTime" && patch[key] ? new Date(Number(patch[key]) || (patch[key] as string)) : patch[key];
|
||||||
|
sets.push(`${column} = $${paramIndex++}`);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return this.findById(incidentId, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
values.push(incidentId, teamId);
|
||||||
|
|
||||||
|
const result = await this.pool.query<IncidentRow>(
|
||||||
|
`UPDATE incidents SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: `Failed to update incident with id ${incidentId}`, status: 500 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteByMonitorId = async (monitorId: string, teamId: string): Promise<number> => {
|
||||||
|
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id = $1 AND team_id = $2`, [monitorId, teamId]);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
|
||||||
|
if (!monitorIds.length) {
|
||||||
|
const result = await this.pool.query(`DELETE FROM incidents`);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
}
|
||||||
|
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id != ALL($1)`, [monitorIds]);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
private buildWhere = (teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string) => {
|
||||||
|
const conditions: string[] = ["team_id = $1"];
|
||||||
|
const values: unknown[] = [teamId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
conditions.push(`created_at >= $${paramIndex++}`);
|
||||||
|
values.push(startDate);
|
||||||
|
}
|
||||||
|
if (status !== undefined) {
|
||||||
|
conditions.push(`status = $${paramIndex++}`);
|
||||||
|
values.push(status);
|
||||||
|
}
|
||||||
|
if (monitorId) {
|
||||||
|
conditions.push(`monitor_id = $${paramIndex++}`);
|
||||||
|
values.push(monitorId);
|
||||||
|
}
|
||||||
|
if (resolutionType) {
|
||||||
|
conditions.push(`resolution_type = $${paramIndex++}`);
|
||||||
|
values.push(resolutionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conditions, values, paramIndex };
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: IncidentRow): Incident => ({
|
||||||
|
id: row.id,
|
||||||
|
monitorId: row.monitor_id,
|
||||||
|
teamId: row.team_id,
|
||||||
|
startTime: row.start_time.toISOString(),
|
||||||
|
endTime: row.end_time ? row.end_time.toISOString() : null,
|
||||||
|
status: row.status,
|
||||||
|
message: row.message ?? null,
|
||||||
|
statusCode: row.status_code ?? null,
|
||||||
|
resolutionType: row.resolution_type ?? null,
|
||||||
|
resolvedBy: row.resolved_by ?? null,
|
||||||
|
resolvedByEmail: row.resolved_by_email ?? null,
|
||||||
|
comment: row.comment ?? null,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,38 +1,54 @@
|
|||||||
export * from "@/repositories/monitors/IMonitorsRepository.js";
|
export * from "@/repositories/monitors/IMonitorsRepository.js";
|
||||||
export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js";
|
export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js";
|
||||||
|
export { TimescaleMonitorsRepository } from "@/repositories/monitors/TimescaleMonitorsRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/checks/IChecksRepository.js";
|
export * from "@/repositories/checks/IChecksRepository.js";
|
||||||
export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js";
|
export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js";
|
||||||
|
export { TimescaleChecksRepository } from "@/repositories/checks/TimescaleChecksRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js";
|
export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js";
|
||||||
export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js";
|
export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js";
|
||||||
|
export { TimescaleMonitorStatsRepository } from "@/repositories/monitor-stats/TimescaleMonitorStatsRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/status-pages/IStatusPagesRepository.js";
|
export * from "@/repositories/status-pages/IStatusPagesRepository.js";
|
||||||
export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js";
|
export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js";
|
||||||
|
export { TimescaleStatusPagesRepository } from "@/repositories/status-pages/TimescaleStatusPagesRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/users/IUsersRepository.js";
|
export * from "@/repositories/users/IUsersRepository.js";
|
||||||
export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js";
|
export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js";
|
||||||
|
export { TimescaleUsersRepository } from "@/repositories/users/TimescaleUsersRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/invites/IInvitesRepository.js";
|
export * from "@/repositories/invites/IInvitesRepository.js";
|
||||||
export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js";
|
export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js";
|
||||||
|
export { TimescaleInvitesRepository } from "@/repositories/invites/TimescaleInvitesRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js";
|
export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js";
|
||||||
export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js";
|
export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js";
|
||||||
|
export { TimescaleRecoveryTokensRepository } from "@/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/settings/ISettingsRepository.js";
|
export * from "@/repositories/settings/ISettingsRepository.js";
|
||||||
export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js";
|
export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js";
|
||||||
|
export { TimescaleSettingsRepository } from "@/repositories/settings/TimescaleSettingsRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/notifications/INotificationsRepository.js";
|
export * from "@/repositories/notifications/INotificationsRepository.js";
|
||||||
export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js";
|
export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js";
|
||||||
|
export { TimescaleNotificationsRepository } from "@/repositories/notifications/TimescaleNotificationsRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/incidents/IIncidentsRepository.js";
|
export * from "@/repositories/incidents/IIncidentsRepository.js";
|
||||||
export { default as MongoIncidentRepository } from "@/repositories/incidents/MongoIncidentRepository.js";
|
export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js";
|
||||||
|
export { TimescaleIncidentsRepository } from "@/repositories/incidents/TimescaleIncidentsRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/teams/ITeamsRepository.js";
|
export * from "@/repositories/teams/ITeamsRepository.js";
|
||||||
export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js";
|
export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js";
|
||||||
|
export { TimescaleTeamsRepository } from "@/repositories/teams/TimescaleTeamsRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js";
|
export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js";
|
||||||
export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js";
|
export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js";
|
||||||
|
export { TimescaleMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/geo-checks/IGeoChecksRepository.js";
|
export * from "@/repositories/geo-checks/IGeoChecksRepository.js";
|
||||||
export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
|
export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
|
||||||
|
|
||||||
|
export * from "@/repositories/dlq/IDLQRepository.js";
|
||||||
|
export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js";
|
||||||
|
export { TimescaleGeoChecksRepository } from "@/repositories/geo-checks/TimescaleGeoChecksRepository.js";
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import type { IInvitesRepository } from "@/repositories/invites/IInvitesRepository.js";
|
||||||
|
import type { Invite } from "@/types/invite.js";
|
||||||
|
import type { UserRole } from "@/types/user.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
const parsePostgresArray = (value: unknown): UserRole[] => {
|
||||||
|
if (typeof value !== "string") return [];
|
||||||
|
const inner = value.replace(/^\{|\}$/g, "");
|
||||||
|
if (inner === "") return [];
|
||||||
|
return inner.split(",") as UserRole[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InviteRow {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
team_id: string;
|
||||||
|
roles: UserRole[];
|
||||||
|
token: string;
|
||||||
|
expiry: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = `id, email, team_id, roles, token, expiry, created_at, updated_at`;
|
||||||
|
|
||||||
|
export class TimescaleInvitesRepository implements IInvitesRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (invite: Partial<Invite>): Promise<Invite> => {
|
||||||
|
// Delete existing invites for this email
|
||||||
|
await this.pool.query(`DELETE FROM invites WHERE email = $1`, [invite.email]);
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
const result = await this.pool.query<InviteRow>(
|
||||||
|
`INSERT INTO invites (email, team_id, roles, token, expiry)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[invite.email, invite.teamId, invite.role ?? ["user"], token, invite.expiry ? new Date(invite.expiry) : null]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Failed to create invite", status: 500 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByToken = async (token: string): Promise<Invite> => {
|
||||||
|
const result = await this.pool.query<InviteRow>(`SELECT ${COLUMNS} FROM invites WHERE token = $1`, [token]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Invite not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByTokenAndDelete = async (token: string): Promise<Invite> => {
|
||||||
|
const result = await this.pool.query<InviteRow>(`DELETE FROM invites WHERE token = $1 RETURNING ${COLUMNS}`, [token]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Invite not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: InviteRow): Invite => ({
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
teamId: row.team_id,
|
||||||
|
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
|
||||||
|
token: row.token,
|
||||||
|
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js";
|
||||||
|
import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
interface MaintenanceWindowRow {
|
||||||
|
id: string;
|
||||||
|
monitor_id: string;
|
||||||
|
team_id: string;
|
||||||
|
active: boolean;
|
||||||
|
name: string;
|
||||||
|
duration: number;
|
||||||
|
duration_unit: DurationUnit;
|
||||||
|
repeat: number;
|
||||||
|
start_time: Date;
|
||||||
|
end_time: Date;
|
||||||
|
expiry: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = `id, monitor_id, team_id, active, name, duration, duration_unit, repeat,
|
||||||
|
start_time, end_time, expiry, created_at, updated_at`;
|
||||||
|
|
||||||
|
export class TimescaleMaintenanceWindowsRepository implements IMaintenanceWindowsRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
|
||||||
|
const startTime = data.start ? new Date(data.start) : null;
|
||||||
|
const endTime = data.end ? new Date(data.end) : null;
|
||||||
|
// One-time windows expire at end time
|
||||||
|
const expiry = data.repeat === 0 ? endTime : null;
|
||||||
|
|
||||||
|
const result = await this.pool.query<MaintenanceWindowRow>(
|
||||||
|
`INSERT INTO maintenance_windows (monitor_id, team_id, active, name, duration, duration_unit, repeat, start_time, end_time, expiry)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[
|
||||||
|
data.monitorId,
|
||||||
|
data.teamId,
|
||||||
|
data.active ?? true,
|
||||||
|
data.name ?? null,
|
||||||
|
data.duration ?? null,
|
||||||
|
data.durationUnit ?? null,
|
||||||
|
data.repeat ?? 0,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
expiry,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Failed to create maintenance window", status: 500 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
|
||||||
|
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE id = $1 AND team_id = $2`, [
|
||||||
|
id,
|
||||||
|
teamId,
|
||||||
|
]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Maintenance Window not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByMonitorId = async (monitorId: string, teamId: string): Promise<MaintenanceWindow[]> => {
|
||||||
|
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE monitor_id = $1 AND team_id = $2`, [
|
||||||
|
monitorId,
|
||||||
|
teamId,
|
||||||
|
]);
|
||||||
|
return result.rows.map(this.toEntity);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByTeamId = async (
|
||||||
|
teamId: string,
|
||||||
|
page: number,
|
||||||
|
rowsPerPage: number,
|
||||||
|
field?: string,
|
||||||
|
order?: string,
|
||||||
|
active?: boolean
|
||||||
|
): Promise<MaintenanceWindow[]> => {
|
||||||
|
const conditions: string[] = ["team_id = $1"];
|
||||||
|
const values: unknown[] = [teamId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
conditions.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldMap: Record<string, string> = {
|
||||||
|
createdAt: "created_at",
|
||||||
|
name: "name",
|
||||||
|
start: "start_time",
|
||||||
|
end: "end_time",
|
||||||
|
active: "active",
|
||||||
|
};
|
||||||
|
const sortColumn = field ? (fieldMap[field] ?? "created_at") : "created_at";
|
||||||
|
const sortDirection = order === "asc" ? "ASC" : "DESC";
|
||||||
|
|
||||||
|
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
|
||||||
|
|
||||||
|
const result = await this.pool.query<MaintenanceWindowRow>(
|
||||||
|
`SELECT ${COLUMNS} FROM maintenance_windows
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY ${sortColumn} ${sortDirection}
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||||
|
[...values, rowsPerPage, offset]
|
||||||
|
);
|
||||||
|
return result.rows.map(this.toEntity);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateById = async (id: string, teamId: string, patch: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldMap: [keyof MaintenanceWindow, string, boolean][] = [
|
||||||
|
["active", "active", false],
|
||||||
|
["name", "name", false],
|
||||||
|
["duration", "duration", false],
|
||||||
|
["durationUnit", "duration_unit", false],
|
||||||
|
["repeat", "repeat", false],
|
||||||
|
["start", "start_time", true],
|
||||||
|
["end", "end_time", true],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, column, isDate] of fieldMap) {
|
||||||
|
if (patch[key] !== undefined) {
|
||||||
|
sets.push(`${column} = $${paramIndex++}`);
|
||||||
|
values.push(isDate ? new Date(patch[key] as string) : patch[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return this.findById(id, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
values.push(id, teamId);
|
||||||
|
|
||||||
|
const result = await this.pool.query<MaintenanceWindowRow>(
|
||||||
|
`UPDATE maintenance_windows SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
|
||||||
|
const result = await this.pool.query<MaintenanceWindowRow>(
|
||||||
|
`DELETE FROM maintenance_windows WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`,
|
||||||
|
[id, teamId]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
countByTeamId = async (teamId: string, active?: boolean): Promise<number> => {
|
||||||
|
const conditions: string[] = ["team_id = $1"];
|
||||||
|
const values: unknown[] = [teamId];
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
conditions.push(`active = $2`);
|
||||||
|
values.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM maintenance_windows WHERE ${conditions.join(" AND ")}`, values);
|
||||||
|
return result.rows[0].count;
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: MaintenanceWindowRow): MaintenanceWindow => ({
|
||||||
|
id: row.id,
|
||||||
|
monitorId: row.monitor_id,
|
||||||
|
teamId: row.team_id,
|
||||||
|
active: row.active,
|
||||||
|
name: row.name,
|
||||||
|
duration: row.duration,
|
||||||
|
durationUnit: row.duration_unit,
|
||||||
|
repeat: row.repeat,
|
||||||
|
start: row.start_time.toISOString(),
|
||||||
|
end: row.end_time.toISOString(),
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { IMonitorStatsRepository } from "./IMonitorStatsRepository.js";
|
||||||
|
import type { MonitorStats } from "@/types/monitorStats.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
interface MonitorStatsRow {
|
||||||
|
id: string;
|
||||||
|
monitor_id: string;
|
||||||
|
avg_response_time: number;
|
||||||
|
max_response_time: number;
|
||||||
|
total_checks: number;
|
||||||
|
total_up_checks: number;
|
||||||
|
total_down_checks: number;
|
||||||
|
uptime_percentage: number;
|
||||||
|
last_check_timestamp: Date | null;
|
||||||
|
last_response_time: number;
|
||||||
|
time_of_last_failure: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = `id, monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks,
|
||||||
|
total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure,
|
||||||
|
created_at, updated_at`;
|
||||||
|
|
||||||
|
export class TimescaleMonitorStatsRepository implements IMonitorStatsRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
|
||||||
|
const result = await this.pool.query<MonitorStatsRow>(
|
||||||
|
`INSERT INTO monitor_stats (monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks,
|
||||||
|
total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[
|
||||||
|
data.monitorId,
|
||||||
|
data.avgResponseTime,
|
||||||
|
data.maxResponseTime,
|
||||||
|
data.totalChecks,
|
||||||
|
data.totalUpChecks,
|
||||||
|
data.totalDownChecks,
|
||||||
|
data.uptimePercentage,
|
||||||
|
data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null,
|
||||||
|
data.lastResponseTime,
|
||||||
|
data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Failed to create monitor stats", status: 500 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
|
||||||
|
const result = await this.pool.query<MonitorStatsRow>(`SELECT ${COLUMNS} FROM monitor_stats WHERE monitor_id = $1`, [monitorId]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Monitor stats not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateByMonitorId = async (monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
|
||||||
|
const result = await this.pool.query<MonitorStatsRow>(
|
||||||
|
`UPDATE monitor_stats SET
|
||||||
|
avg_response_time = $2,
|
||||||
|
max_response_time = $3,
|
||||||
|
total_checks = $4,
|
||||||
|
total_up_checks = $5,
|
||||||
|
total_down_checks = $6,
|
||||||
|
uptime_percentage = $7,
|
||||||
|
last_check_timestamp = $8,
|
||||||
|
last_response_time = $9,
|
||||||
|
time_of_last_failure = $10,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE monitor_id = $1
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[
|
||||||
|
monitorId,
|
||||||
|
data.avgResponseTime,
|
||||||
|
data.maxResponseTime,
|
||||||
|
data.totalChecks,
|
||||||
|
data.totalUpChecks,
|
||||||
|
data.totalDownChecks,
|
||||||
|
data.uptimePercentage,
|
||||||
|
data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null,
|
||||||
|
data.lastResponseTime,
|
||||||
|
data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Monitor stats not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
|
||||||
|
const result = await this.pool.query<MonitorStatsRow>(`DELETE FROM monitor_stats WHERE monitor_id = $1 RETURNING ${COLUMNS}`, [monitorId]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Monitor stats not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteByMonitorIds = async (monitorIds: string[]): Promise<number> => {
|
||||||
|
if (!monitorIds.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id = ANY($1)`, [monitorIds]);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
|
||||||
|
if (!monitorIds.length) {
|
||||||
|
const result = await this.pool.query(`DELETE FROM monitor_stats`);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
}
|
||||||
|
const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id != ALL($1)`, [monitorIds]);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: MonitorStatsRow): MonitorStats => ({
|
||||||
|
id: row.id,
|
||||||
|
monitorId: row.monitor_id,
|
||||||
|
avgResponseTime: row.avg_response_time,
|
||||||
|
maxResponseTime: row.max_response_time,
|
||||||
|
totalChecks: Number(row.total_checks),
|
||||||
|
totalUpChecks: Number(row.total_up_checks),
|
||||||
|
totalDownChecks: Number(row.total_down_checks),
|
||||||
|
uptimePercentage: row.uptime_percentage,
|
||||||
|
lastCheckTimestamp: row.last_check_timestamp ? row.last_check_timestamp.getTime() : 0,
|
||||||
|
lastResponseTime: row.last_response_time,
|
||||||
|
timeOfLastFailure: row.time_of_last_failure ? row.time_of_last_failure.getTime() : undefined,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -386,6 +386,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
selectedDisks: doc.selectedDisks ?? [],
|
selectedDisks: doc.selectedDisks ?? [],
|
||||||
gameId: doc.gameId ?? undefined,
|
gameId: doc.gameId ?? undefined,
|
||||||
grpcServiceName: doc.grpcServiceName ?? undefined,
|
grpcServiceName: doc.grpcServiceName ?? undefined,
|
||||||
|
strategy: doc.strategy ?? undefined,
|
||||||
group: doc.group ?? null,
|
group: doc.group ?? null,
|
||||||
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
||||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||||
@@ -445,6 +446,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
selectedDisks: doc.selectedDisks ?? [],
|
selectedDisks: doc.selectedDisks ?? [],
|
||||||
gameId: doc.gameId ?? undefined,
|
gameId: doc.gameId ?? undefined,
|
||||||
grpcServiceName: doc.grpcServiceName ?? undefined,
|
grpcServiceName: doc.grpcServiceName ?? undefined,
|
||||||
|
strategy: doc.strategy ?? undefined,
|
||||||
group: doc.group ?? null,
|
group: doc.group ?? null,
|
||||||
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
||||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import { INotificationsRepository } from "@/repositories/notifications/INotificationsRepository.js";
|
||||||
|
import type { Notification, NotificationChannel } from "@/types/notification.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
interface NotificationRow {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
team_id: string;
|
||||||
|
type: NotificationChannel;
|
||||||
|
notification_name: string;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
homeserver_url: string | null;
|
||||||
|
room_id: string | null;
|
||||||
|
access_token: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone,
|
||||||
|
homeserver_url, room_id, access_token, created_at, updated_at`;
|
||||||
|
|
||||||
|
export class TimescaleNotificationsRepository implements INotificationsRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (data: Partial<Notification>): Promise<Notification> => {
|
||||||
|
const result = await this.pool.query<NotificationRow>(
|
||||||
|
`INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[
|
||||||
|
data.userId,
|
||||||
|
data.teamId,
|
||||||
|
data.type,
|
||||||
|
data.notificationName,
|
||||||
|
data.address ?? null,
|
||||||
|
data.phone ?? null,
|
||||||
|
data.homeserverUrl ?? null,
|
||||||
|
data.roomId ?? null,
|
||||||
|
data.accessToken ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Failed to create notification", status: 500 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findById = async (id: string, teamId: string): Promise<Notification> => {
|
||||||
|
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE id = $1 AND team_id = $2`, [id, teamId]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Notification not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findNotificationsByIds = async (ids: string[]): Promise<Notification[]> => {
|
||||||
|
if (!ids.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE id = ANY($1)`, [ids]);
|
||||||
|
return result.rows.map(this.toEntity);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByTeamId = async (teamId: string): Promise<Notification[]> => {
|
||||||
|
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE team_id = $1`, [teamId]);
|
||||||
|
return result.rows.map(this.toEntity);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateById = async (id: string, teamId: string, patch: Partial<Notification>): Promise<Notification> => {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldMap: [keyof Notification, string][] = [
|
||||||
|
["type", "type"],
|
||||||
|
["notificationName", "notification_name"],
|
||||||
|
["address", "address"],
|
||||||
|
["phone", "phone"],
|
||||||
|
["homeserverUrl", "homeserver_url"],
|
||||||
|
["roomId", "room_id"],
|
||||||
|
["accessToken", "access_token"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, column] of fieldMap) {
|
||||||
|
if (patch[key] !== undefined) {
|
||||||
|
sets.push(`${column} = $${paramIndex++}`);
|
||||||
|
values.push(patch[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return this.findById(id, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
values.push(id, teamId);
|
||||||
|
|
||||||
|
const result = await this.pool.query<NotificationRow>(
|
||||||
|
`UPDATE notifications SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Notification not found or could not be updated", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteById = async (id: string, teamId: string): Promise<Notification> => {
|
||||||
|
const result = await this.pool.query<NotificationRow>(`DELETE FROM notifications WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`, [
|
||||||
|
id,
|
||||||
|
teamId,
|
||||||
|
]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Notification not found or could not be deleted", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: NotificationRow): Notification => ({
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
teamId: row.team_id,
|
||||||
|
type: row.type,
|
||||||
|
notificationName: row.notification_name,
|
||||||
|
address: row.address ?? undefined,
|
||||||
|
phone: row.phone ?? undefined,
|
||||||
|
homeserverUrl: row.homeserver_url ?? undefined,
|
||||||
|
roomId: row.room_id ?? undefined,
|
||||||
|
accessToken: row.access_token ?? undefined,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js";
|
||||||
|
import type { RecoveryToken } from "@/types/recoveryToken.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
interface RecoveryTokenRow {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
expiry: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = `id, email, token, expiry, created_at, updated_at`;
|
||||||
|
|
||||||
|
export class TimescaleRecoveryTokensRepository implements IRecoveryTokensRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (email: string): Promise<RecoveryToken> => {
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
const result = await this.pool.query<RecoveryTokenRow>(
|
||||||
|
`INSERT INTO recovery_tokens (email, token)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (email) DO UPDATE SET token = $2, updated_at = NOW()
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[email, token]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Failed to create recovery token", status: 500 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByToken = async (token: string): Promise<RecoveryToken> => {
|
||||||
|
const result = await this.pool.query<RecoveryTokenRow>(`SELECT ${COLUMNS} FROM recovery_tokens WHERE token = $1`, [token]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Recovery token not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteManyByEmail = async (email: string): Promise<number> => {
|
||||||
|
const result = await this.pool.query(`DELETE FROM recovery_tokens WHERE email = $1`, [email]);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: RecoveryTokenRow): RecoveryToken => ({
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
token: row.token,
|
||||||
|
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { ISettingsRepository } from "./ISettingsRepository.js";
|
||||||
|
import type { Settings, SettingsUpdate } from "@/types/settings.js";
|
||||||
|
|
||||||
|
interface SettingsRow {
|
||||||
|
id: string;
|
||||||
|
check_ttl: number;
|
||||||
|
language: string | null;
|
||||||
|
jwt_secret: string | null;
|
||||||
|
pagespeed_api_key: string | null;
|
||||||
|
system_email_host: string | null;
|
||||||
|
system_email_port: number | null;
|
||||||
|
system_email_address: string | null;
|
||||||
|
system_email_password: string | null;
|
||||||
|
system_email_user: string | null;
|
||||||
|
system_email_connection_host: string | null;
|
||||||
|
system_email_tls_servername: string | null;
|
||||||
|
system_email_secure: boolean | null;
|
||||||
|
system_email_pool: boolean | null;
|
||||||
|
system_email_ignore_tls: boolean | null;
|
||||||
|
system_email_require_tls: boolean | null;
|
||||||
|
system_email_reject_unauthorized: boolean | null;
|
||||||
|
show_url: boolean | null;
|
||||||
|
version: string | null;
|
||||||
|
threshold_cpu_usage: number | null;
|
||||||
|
threshold_memory_usage: number | null;
|
||||||
|
threshold_disk_usage: number | null;
|
||||||
|
threshold_temperature: number | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key,
|
||||||
|
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
|
||||||
|
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
|
||||||
|
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
|
||||||
|
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature,
|
||||||
|
created_at, updated_at`;
|
||||||
|
|
||||||
|
export class TimescaleSettingsRepository implements ISettingsRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (settings: Partial<Settings>): Promise<Settings> => {
|
||||||
|
const result = await this.pool.query<SettingsRow>(
|
||||||
|
`INSERT INTO app_settings (check_ttl, language, jwt_secret, pagespeed_api_key,
|
||||||
|
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
|
||||||
|
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
|
||||||
|
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
|
||||||
|
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[
|
||||||
|
settings.checkTTL ?? 30,
|
||||||
|
settings.language ?? null,
|
||||||
|
settings.jwtSecret ?? null,
|
||||||
|
settings.pagespeedApiKey ?? null,
|
||||||
|
settings.systemEmailHost ?? null,
|
||||||
|
settings.systemEmailPort ?? null,
|
||||||
|
settings.systemEmailAddress ?? null,
|
||||||
|
settings.systemEmailPassword ?? null,
|
||||||
|
settings.systemEmailUser ?? null,
|
||||||
|
settings.systemEmailConnectionHost ?? null,
|
||||||
|
settings.systemEmailTLSServername ?? null,
|
||||||
|
settings.systemEmailSecure ?? false,
|
||||||
|
settings.systemEmailPool ?? false,
|
||||||
|
settings.systemEmailIgnoreTLS ?? false,
|
||||||
|
settings.systemEmailRequireTLS ?? false,
|
||||||
|
settings.systemEmailRejectUnauthorized ?? true,
|
||||||
|
settings.showURL ?? false,
|
||||||
|
settings.version ?? 1,
|
||||||
|
settings.globalThresholds?.cpu ?? null,
|
||||||
|
settings.globalThresholds?.memory ?? null,
|
||||||
|
settings.globalThresholds?.disk ?? null,
|
||||||
|
settings.globalThresholds?.temperature ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("Failed to create app settings");
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findSingleton = async (): Promise<Settings | null> => {
|
||||||
|
const result = await this.pool.query<SettingsRow>(`SELECT ${COLUMNS} FROM app_settings LIMIT 1`);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
update = async (settings: SettingsUpdate): Promise<Settings> => {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldMap: [keyof SettingsUpdate, string][] = [
|
||||||
|
["checkTTL", "check_ttl"],
|
||||||
|
["language", "language"],
|
||||||
|
["jwtSecret", "jwt_secret"],
|
||||||
|
["pagespeedApiKey", "pagespeed_api_key"],
|
||||||
|
["systemEmailHost", "system_email_host"],
|
||||||
|
["systemEmailPort", "system_email_port"],
|
||||||
|
["systemEmailAddress", "system_email_address"],
|
||||||
|
["systemEmailPassword", "system_email_password"],
|
||||||
|
["systemEmailUser", "system_email_user"],
|
||||||
|
["systemEmailConnectionHost", "system_email_connection_host"],
|
||||||
|
["systemEmailTLSServername", "system_email_tls_servername"],
|
||||||
|
["systemEmailSecure", "system_email_secure"],
|
||||||
|
["systemEmailPool", "system_email_pool"],
|
||||||
|
["systemEmailIgnoreTLS", "system_email_ignore_tls"],
|
||||||
|
["systemEmailRequireTLS", "system_email_require_tls"],
|
||||||
|
["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"],
|
||||||
|
["showURL", "show_url"],
|
||||||
|
["version", "version"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, column] of fieldMap) {
|
||||||
|
if (key in settings) {
|
||||||
|
sets.push(`${column} = $${paramIndex++}`);
|
||||||
|
// null means unset the field
|
||||||
|
values.push(settings[key] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle globalThresholds
|
||||||
|
if ("globalThresholds" in settings) {
|
||||||
|
const thresholds = settings.globalThresholds;
|
||||||
|
if (thresholds === null || thresholds === undefined) {
|
||||||
|
sets.push(`threshold_cpu_usage = NULL`);
|
||||||
|
sets.push(`threshold_memory_usage = NULL`);
|
||||||
|
sets.push(`threshold_disk_usage = NULL`);
|
||||||
|
sets.push(`threshold_temperature = NULL`);
|
||||||
|
} else {
|
||||||
|
if ("cpu" in thresholds) {
|
||||||
|
sets.push(`threshold_cpu_usage = $${paramIndex++}`);
|
||||||
|
values.push(thresholds.cpu ?? null);
|
||||||
|
}
|
||||||
|
if ("memory" in thresholds) {
|
||||||
|
sets.push(`threshold_memory_usage = $${paramIndex++}`);
|
||||||
|
values.push(thresholds.memory ?? null);
|
||||||
|
}
|
||||||
|
if ("disk" in thresholds) {
|
||||||
|
sets.push(`threshold_disk_usage = $${paramIndex++}`);
|
||||||
|
values.push(thresholds.disk ?? null);
|
||||||
|
}
|
||||||
|
if ("temperature" in thresholds) {
|
||||||
|
sets.push(`threshold_temperature = $${paramIndex++}`);
|
||||||
|
values.push(thresholds.temperature ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
const existing = await this.findSingleton();
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error("App settings not found");
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
|
||||||
|
// Upsert: update the single row, or insert if none exists
|
||||||
|
const result = await this.pool.query<SettingsRow>(`UPDATE app_settings SET ${sets.join(", ")} RETURNING ${COLUMNS}`, values);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
// No row existed, create one
|
||||||
|
return this.create(settings as Partial<Settings>);
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteLegacy = async (): Promise<boolean> => {
|
||||||
|
// No legacy rows in TimescaleDB — singleton is enforced by trigger
|
||||||
|
const result = await this.pool.query(`DELETE FROM app_settings WHERE version IS NULL`);
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: SettingsRow): Settings => ({
|
||||||
|
id: row.id,
|
||||||
|
checkTTL: row.check_ttl,
|
||||||
|
language: row.language ?? "",
|
||||||
|
jwtSecret: row.jwt_secret ?? undefined,
|
||||||
|
pagespeedApiKey: row.pagespeed_api_key ?? undefined,
|
||||||
|
systemEmailHost: row.system_email_host ?? undefined,
|
||||||
|
systemEmailPort: row.system_email_port ?? undefined,
|
||||||
|
systemEmailAddress: row.system_email_address ?? undefined,
|
||||||
|
systemEmailPassword: row.system_email_password ?? undefined,
|
||||||
|
systemEmailUser: row.system_email_user ?? undefined,
|
||||||
|
systemEmailConnectionHost: row.system_email_connection_host ?? undefined,
|
||||||
|
systemEmailTLSServername: row.system_email_tls_servername ?? undefined,
|
||||||
|
systemEmailSecure: row.system_email_secure ?? false,
|
||||||
|
systemEmailPool: row.system_email_pool ?? false,
|
||||||
|
systemEmailIgnoreTLS: row.system_email_ignore_tls ?? false,
|
||||||
|
systemEmailRequireTLS: row.system_email_require_tls ?? false,
|
||||||
|
systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true,
|
||||||
|
showURL: row.show_url ?? false,
|
||||||
|
singleton: true,
|
||||||
|
version: Number(row.version ?? 1),
|
||||||
|
globalThresholds:
|
||||||
|
row.threshold_cpu_usage !== null ||
|
||||||
|
row.threshold_memory_usage !== null ||
|
||||||
|
row.threshold_disk_usage !== null ||
|
||||||
|
row.threshold_temperature !== null
|
||||||
|
? {
|
||||||
|
cpu: row.threshold_cpu_usage ?? undefined,
|
||||||
|
memory: row.threshold_memory_usage ?? undefined,
|
||||||
|
disk: row.threshold_disk_usage ?? undefined,
|
||||||
|
temperature: row.threshold_temperature ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { IStatusPagesRepository } from "./IStatusPagesRepository.js";
|
||||||
|
import type { StatusPage, StatusPageType } from "@/types/statusPage.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
const parsePostgresArray = (value: unknown): StatusPageType[] => {
|
||||||
|
if (typeof value !== "string") return [];
|
||||||
|
const inner = value.replace(/^\{|\}$/g, "");
|
||||||
|
if (inner === "") return [];
|
||||||
|
return inner.split(",") as StatusPageType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatusPageRow {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
team_id: string;
|
||||||
|
types: StatusPageType[];
|
||||||
|
company_name: string;
|
||||||
|
url: string;
|
||||||
|
timezone: string | null;
|
||||||
|
color: string;
|
||||||
|
logo_data: Buffer | null;
|
||||||
|
logo_content_type: string | null;
|
||||||
|
is_published: boolean;
|
||||||
|
show_charts: boolean;
|
||||||
|
show_uptime_percentage: boolean;
|
||||||
|
show_admin_login_link: boolean;
|
||||||
|
show_infrastructure: boolean;
|
||||||
|
custom_css: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color,
|
||||||
|
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
|
||||||
|
show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`;
|
||||||
|
|
||||||
|
export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
|
||||||
|
const result = await this.pool.query<StatusPageRow>(
|
||||||
|
`INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color,
|
||||||
|
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
|
||||||
|
show_admin_login_link, show_infrastructure, custom_css)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
data.type ?? ["uptime"],
|
||||||
|
data.companyName ?? "",
|
||||||
|
data.url ?? "",
|
||||||
|
data.timezone ?? null,
|
||||||
|
data.color ?? "#4169E1",
|
||||||
|
image?.buffer ?? null,
|
||||||
|
image?.mimetype ?? null,
|
||||||
|
data.isPublished ?? false,
|
||||||
|
data.showCharts ?? false,
|
||||||
|
data.showUptimePercentage ?? false,
|
||||||
|
data.showAdminLoginLink ?? false,
|
||||||
|
data.showInfrastructure ?? false,
|
||||||
|
data.customCSS ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Failed to create status page", status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert monitor associations
|
||||||
|
if (data.monitors?.length) {
|
||||||
|
for (const [i, monitorId] of data.monitors.entries()) {
|
||||||
|
await this.pool.query(
|
||||||
|
`INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
[row.id, monitorId, i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.subMonitors?.length) {
|
||||||
|
for (const [i, monitorId] of data.subMonitors.entries()) {
|
||||||
|
await this.pool.query(
|
||||||
|
`INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
[row.id, monitorId, i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByUrl = async (url: string): Promise<StatusPage> => {
|
||||||
|
const result = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE url = $1`, [url]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Status page not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.loadEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findByTeamId = async (teamId: string): Promise<StatusPage[]> => {
|
||||||
|
const result = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE team_id = $1`, [teamId]);
|
||||||
|
const pages: StatusPage[] = [];
|
||||||
|
for (const row of result.rows) {
|
||||||
|
pages.push(await this.loadEntity(row));
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateById = async (
|
||||||
|
id: string,
|
||||||
|
teamId: string,
|
||||||
|
image: Express.Multer.File | undefined,
|
||||||
|
patch: Partial<StatusPage> & { removeLogo?: string }
|
||||||
|
): Promise<StatusPage> => {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldMap: [keyof StatusPage, string][] = [
|
||||||
|
["type", "types"],
|
||||||
|
["companyName", "company_name"],
|
||||||
|
["url", "url"],
|
||||||
|
["timezone", "timezone"],
|
||||||
|
["color", "color"],
|
||||||
|
["isPublished", "is_published"],
|
||||||
|
["showCharts", "show_charts"],
|
||||||
|
["showUptimePercentage", "show_uptime_percentage"],
|
||||||
|
["showAdminLoginLink", "show_admin_login_link"],
|
||||||
|
["showInfrastructure", "show_infrastructure"],
|
||||||
|
["customCSS", "custom_css"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, column] of fieldMap) {
|
||||||
|
if (patch[key] !== undefined) {
|
||||||
|
sets.push(`${column} = $${paramIndex++}`);
|
||||||
|
values.push(patch[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
sets.push(`logo_data = $${paramIndex++}`);
|
||||||
|
values.push(image.buffer);
|
||||||
|
sets.push(`logo_content_type = $${paramIndex++}`);
|
||||||
|
values.push(image.mimetype);
|
||||||
|
} else if (patch.removeLogo === "true") {
|
||||||
|
sets.push(`logo_data = NULL`);
|
||||||
|
sets.push(`logo_content_type = NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length > 0) {
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
values.push(id, teamId);
|
||||||
|
|
||||||
|
const result = await this.pool.query<StatusPageRow>(
|
||||||
|
`UPDATE status_pages SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
|
||||||
|
RETURNING ${COLUMNS}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Status page not found", status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update monitor associations if provided
|
||||||
|
if (patch.monitors !== undefined) {
|
||||||
|
await this.pool.query(`DELETE FROM status_page_monitors WHERE status_page_id = $1`, [id]);
|
||||||
|
for (const [i, monitorId] of patch.monitors.entries()) {
|
||||||
|
await this.pool.query(
|
||||||
|
`INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
[id, monitorId, i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.subMonitors !== undefined) {
|
||||||
|
await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE status_page_id = $1`, [id]);
|
||||||
|
for (const [i, monitorId] of patch.subMonitors.entries()) {
|
||||||
|
await this.pool.query(
|
||||||
|
`INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
[id, monitorId, i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch to return full entity
|
||||||
|
const freshResult = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
|
||||||
|
const freshRow = freshResult.rows[0];
|
||||||
|
if (!freshRow) {
|
||||||
|
throw new AppError({ message: "Status page not found", status: 404 });
|
||||||
|
}
|
||||||
|
return this.loadEntity(freshRow);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteById = async (id: string, teamId: string): Promise<StatusPage> => {
|
||||||
|
// Load entity before delete (join tables cascade)
|
||||||
|
const existing = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
|
||||||
|
const row = existing.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Status page not found", status: 404 });
|
||||||
|
}
|
||||||
|
const entity = await this.loadEntity(row);
|
||||||
|
|
||||||
|
await this.pool.query(`DELETE FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
|
||||||
|
return entity;
|
||||||
|
};
|
||||||
|
|
||||||
|
removeMonitorFromStatusPages = async (monitorId: string): Promise<number> => {
|
||||||
|
// Return count of distinct status pages affected, matching Mongo's modifiedCount
|
||||||
|
const result = await this.pool.query(
|
||||||
|
`SELECT COUNT(DISTINCT status_page_id)::int AS count FROM (
|
||||||
|
SELECT status_page_id FROM status_page_monitors WHERE monitor_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT status_page_id FROM status_page_sub_monitors WHERE monitor_id = $1
|
||||||
|
) affected`,
|
||||||
|
[monitorId]
|
||||||
|
);
|
||||||
|
const affectedCount = result.rows[0]?.count ?? 0;
|
||||||
|
|
||||||
|
await this.pool.query(`DELETE FROM status_page_monitors WHERE monitor_id = $1`, [monitorId]);
|
||||||
|
await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE monitor_id = $1`, [monitorId]);
|
||||||
|
|
||||||
|
return affectedCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
private loadEntity = async (row: StatusPageRow): Promise<StatusPage> => {
|
||||||
|
const [monitorsResult, subMonitorsResult] = await Promise.all([
|
||||||
|
this.pool.query(`SELECT monitor_id FROM status_page_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]),
|
||||||
|
this.pool.query(`SELECT monitor_id FROM status_page_sub_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
teamId: row.team_id,
|
||||||
|
type: Array.isArray(row.types) ? row.types : parsePostgresArray(row.types),
|
||||||
|
companyName: row.company_name,
|
||||||
|
url: row.url,
|
||||||
|
timezone: row.timezone ?? undefined,
|
||||||
|
color: row.color,
|
||||||
|
monitors: monitorsResult.rows.map((r) => r.monitor_id),
|
||||||
|
subMonitors: subMonitorsResult.rows.map((r) => r.monitor_id),
|
||||||
|
originalMonitors: [],
|
||||||
|
logo: row.logo_data
|
||||||
|
? {
|
||||||
|
data: row.logo_data.toString("base64"),
|
||||||
|
contentType: row.logo_content_type ?? "",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
isPublished: row.is_published,
|
||||||
|
showCharts: row.show_charts,
|
||||||
|
showUptimePercentage: row.show_uptime_percentage,
|
||||||
|
showAdminLoginLink: row.show_admin_login_link,
|
||||||
|
showInfrastructure: row.show_infrastructure,
|
||||||
|
customCSS: row.custom_css ?? "",
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import { ITeamsRepository } from "@/repositories/teams/ITeamsRepository.js";
|
||||||
|
import { Team } from "@/types/team.js";
|
||||||
|
|
||||||
|
export class TimescaleTeamsRepository implements ITeamsRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (email: string): Promise<Team> => {
|
||||||
|
const result = await this.pool.query(`INSERT INTO teams (email) VALUES ($1) RETURNING id, email, created_at, updated_at`, [email]);
|
||||||
|
return this.toEntity(result.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
findAllTeamIds = async (): Promise<string[]> => {
|
||||||
|
const result = await this.pool.query(`SELECT id FROM teams`);
|
||||||
|
return result.rows.map((row) => row.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: { id: string; email: string; created_at: Date; updated_at: Date }): Team => ({
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import type { Pool } from "pg";
|
||||||
|
import { IUsersRepository } from "./IUsersRepository.js";
|
||||||
|
import type { User, UserRole } from "@/types/user.js";
|
||||||
|
import { GenerateAvatarImage } from "@/utils/imageProcessing.js";
|
||||||
|
import { ParseBoolean } from "@/utils/utils.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
|
const SERVICE_NAME = "TimescaleUsersRepository";
|
||||||
|
|
||||||
|
// pg returns custom enum arrays as strings like "{user,admin}" — parse to JS array
|
||||||
|
const parsePostgresArray = (value: unknown): UserRole[] => {
|
||||||
|
if (typeof value !== "string") return [];
|
||||||
|
const inner = value.replace(/^\{|\}$/g, "");
|
||||||
|
if (inner === "") return [];
|
||||||
|
return inner.split(",") as UserRole[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: string;
|
||||||
|
team_id: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
avatar_image: string | null;
|
||||||
|
profile_image: Buffer | null;
|
||||||
|
profile_image_content_type: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
is_verified: boolean;
|
||||||
|
roles: UserRole[];
|
||||||
|
check_ttl: number | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimescaleUsersRepository implements IUsersRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
create = async (user: Partial<User>, imageFile?: Express.Multer.File | null): Promise<User> => {
|
||||||
|
let avatarImage: string | undefined;
|
||||||
|
let profileImage: Buffer | undefined;
|
||||||
|
let profileImageContentType: string | undefined;
|
||||||
|
|
||||||
|
if (imageFile) {
|
||||||
|
profileImage = imageFile.buffer;
|
||||||
|
profileImageContentType = imageFile.mimetype;
|
||||||
|
avatarImage = await GenerateAvatarImage(imageFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pool.query<UserRow>(
|
||||||
|
`INSERT INTO users (team_id, first_name, last_name, email, password, avatar_image, profile_image, profile_image_content_type, is_active, is_verified, roles, check_ttl)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
user.teamId || null,
|
||||||
|
user.firstName,
|
||||||
|
user.lastName,
|
||||||
|
user.email,
|
||||||
|
user.password,
|
||||||
|
avatarImage || null,
|
||||||
|
profileImage || null,
|
||||||
|
profileImageContentType || null,
|
||||||
|
user.isActive ?? true,
|
||||||
|
user.isVerified ?? false,
|
||||||
|
user.role ?? ["user"],
|
||||||
|
user.checkTTL ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 });
|
||||||
|
}
|
||||||
|
const entity = this.toEntity(row);
|
||||||
|
entity.password = "";
|
||||||
|
return entity;
|
||||||
|
};
|
||||||
|
|
||||||
|
findByEmail = async (email: string): Promise<User> => {
|
||||||
|
const result = await this.pool.query<UserRow>(
|
||||||
|
`SELECT id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
|
||||||
|
FROM users WHERE email = $1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findById = async (id: string): Promise<User> => {
|
||||||
|
const result = await this.pool.query<UserRow>(
|
||||||
|
`SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
|
||||||
|
FROM users WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findAll = async (): Promise<User[]> => {
|
||||||
|
const result = await this.pool.query<UserRow>(
|
||||||
|
`SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
|
||||||
|
FROM users`
|
||||||
|
);
|
||||||
|
return result.rows.map(this.toEntity);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateById = async (id: string, patch: Partial<User & { deleteProfileImage?: boolean }>, file?: Express.Multer.File | null): Promise<User> => {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (ParseBoolean(patch.deleteProfileImage) === true) {
|
||||||
|
sets.push(`profile_image = NULL`);
|
||||||
|
sets.push(`profile_image_content_type = NULL`);
|
||||||
|
sets.push(`avatar_image = NULL`);
|
||||||
|
} else if (file) {
|
||||||
|
const avatarImage = await GenerateAvatarImage(file);
|
||||||
|
sets.push(`profile_image = $${paramIndex++}`);
|
||||||
|
values.push(file.buffer);
|
||||||
|
sets.push(`profile_image_content_type = $${paramIndex++}`);
|
||||||
|
values.push(file.mimetype);
|
||||||
|
sets.push(`avatar_image = $${paramIndex++}`);
|
||||||
|
values.push(avatarImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.firstName !== undefined) {
|
||||||
|
sets.push(`first_name = $${paramIndex++}`);
|
||||||
|
values.push(patch.firstName);
|
||||||
|
}
|
||||||
|
if (patch.lastName !== undefined) {
|
||||||
|
sets.push(`last_name = $${paramIndex++}`);
|
||||||
|
values.push(patch.lastName);
|
||||||
|
}
|
||||||
|
if (patch.email !== undefined) {
|
||||||
|
sets.push(`email = $${paramIndex++}`);
|
||||||
|
values.push(patch.email);
|
||||||
|
}
|
||||||
|
if (patch.password !== undefined) {
|
||||||
|
sets.push(`password = $${paramIndex++}`);
|
||||||
|
values.push(patch.password);
|
||||||
|
}
|
||||||
|
if (patch.isActive !== undefined) {
|
||||||
|
sets.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(patch.isActive);
|
||||||
|
}
|
||||||
|
if (patch.isVerified !== undefined) {
|
||||||
|
sets.push(`is_verified = $${paramIndex++}`);
|
||||||
|
values.push(patch.isVerified);
|
||||||
|
}
|
||||||
|
if (patch.role !== undefined) {
|
||||||
|
sets.push(`roles = $${paramIndex++}`);
|
||||||
|
values.push(patch.role);
|
||||||
|
}
|
||||||
|
if (patch.teamId !== undefined) {
|
||||||
|
sets.push(`team_id = $${paramIndex++}`);
|
||||||
|
values.push(patch.teamId);
|
||||||
|
}
|
||||||
|
if (patch.checkTTL !== undefined) {
|
||||||
|
sets.push(`check_ttl = $${paramIndex++}`);
|
||||||
|
values.push(patch.checkTTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const result = await this.pool.query<UserRow>(
|
||||||
|
`UPDATE users SET ${sets.join(", ")} WHERE id = $${paramIndex}
|
||||||
|
RETURNING id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteById = async (id: string): Promise<User> => {
|
||||||
|
const result = await this.pool.query<UserRow>(
|
||||||
|
`DELETE FROM users WHERE id = $1
|
||||||
|
RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
|
||||||
|
}
|
||||||
|
return this.toEntity(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
findSuperAdmin = async (): Promise<boolean> => {
|
||||||
|
const result = await this.pool.query(`SELECT 1 FROM users WHERE 'superadmin' = ANY(roles) LIMIT 1`);
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
private toEntity = (row: UserRow): User => ({
|
||||||
|
id: row.id,
|
||||||
|
firstName: row.first_name,
|
||||||
|
lastName: row.last_name,
|
||||||
|
email: row.email,
|
||||||
|
password: row.password,
|
||||||
|
avatarImage: row.avatar_image ?? undefined,
|
||||||
|
profileImage: undefined,
|
||||||
|
isActive: row.is_active,
|
||||||
|
isVerified: row.is_verified,
|
||||||
|
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
|
||||||
|
teamId: row.team_id ?? "",
|
||||||
|
checkTTL: row.check_ttl ?? undefined,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export interface IGeoChecksService {
|
|||||||
dateRange: string;
|
dateRange: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
rowsPerPage?: number;
|
rowsPerPage?: number;
|
||||||
continent: GeoContinent | GeoContinent[];
|
continent?: GeoContinent | GeoContinent[];
|
||||||
}): Promise<FlatGeoChecksQueryResult>;
|
}): Promise<FlatGeoChecksQueryResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
|
|||||||
export * from "@/service/infrastructure/notificationProviders/slack.js";
|
export * from "@/service/infrastructure/notificationProviders/slack.js";
|
||||||
export * from "@/service/infrastructure/notificationProviders/teams.js";
|
export * from "@/service/infrastructure/notificationProviders/teams.js";
|
||||||
export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
||||||
|
export * from "@/service/infrastructure/notificationProviders/telegram.js";
|
||||||
|
|
||||||
// System services
|
// System services
|
||||||
export * from "@/service/system/settingsService.js";
|
export * from "@/service/system/settingsService.js";
|
||||||
@@ -146,7 +146,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
|
|
||||||
deleteJob = async (monitor: Monitor) => {
|
deleteJob = async (monitor: Monitor) => {
|
||||||
this.scheduler.removeJob(monitor.id);
|
this.scheduler.removeJob(monitor.id);
|
||||||
this.scheduler.removeJob(`${monitor.id}-geo`);
|
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
||||||
|
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
|
||||||
};
|
};
|
||||||
|
|
||||||
pauseJob = async (monitor: Monitor) => {
|
pauseJob = async (monitor: Monitor) => {
|
||||||
@@ -154,7 +155,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
if (result === false) {
|
if (result === false) {
|
||||||
throw new Error("Failed to pause monitor");
|
throw new Error("Failed to pause monitor");
|
||||||
}
|
}
|
||||||
await this.scheduler.pauseJob(`${monitor.id}-geo`);
|
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
||||||
|
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
|
||||||
|
|
||||||
this.logger.debug({
|
this.logger.debug({
|
||||||
message: `Paused monitor ${monitor.id}`,
|
message: `Paused monitor ${monitor.id}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
@@ -177,31 +180,35 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private syncGeoJob = async (monitor: Monitor) => {
|
||||||
|
const geoJobId = `${monitor.id}-geo`;
|
||||||
|
const existingGeoJob = await this.scheduler.getJob(geoJobId);
|
||||||
|
|
||||||
|
// If geoChecks have been disabled, or the monitor type doesn't support them, remove
|
||||||
|
if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) {
|
||||||
|
if (existingGeoJob) this.scheduler.removeJob(geoJobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the job exists, update it
|
||||||
|
if (existingGeoJob) {
|
||||||
|
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, create it
|
||||||
|
this.scheduler.addJob({
|
||||||
|
id: geoJobId,
|
||||||
|
template: "geo-check-job",
|
||||||
|
repeat: monitor.geoCheckInterval,
|
||||||
|
active: monitor.isActive,
|
||||||
|
data: monitor,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
updateJob = async (monitor: Monitor) => {
|
updateJob = async (monitor: Monitor) => {
|
||||||
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
|
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
|
||||||
|
await this.syncGeoJob(monitor);
|
||||||
// Handle geo check job lifecycle
|
|
||||||
const geoJobId = `${monitor.id}-geo`;
|
|
||||||
if (monitor.geoCheckEnabled && supportsGeoCheck(monitor.type)) {
|
|
||||||
// Check if geo job exists
|
|
||||||
const existingGeoJob = await this.scheduler.getJob(geoJobId);
|
|
||||||
if (existingGeoJob) {
|
|
||||||
// Update existing geo job
|
|
||||||
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
|
|
||||||
} else {
|
|
||||||
// Create new geo job
|
|
||||||
this.scheduler.addJob({
|
|
||||||
id: geoJobId,
|
|
||||||
template: "geo-check-job",
|
|
||||||
repeat: monitor.geoCheckInterval,
|
|
||||||
active: monitor.isActive,
|
|
||||||
data: monitor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Remove geo job if disabled or monitor type changed
|
|
||||||
this.scheduler.removeJob(geoJobId);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
shutdown = async () => {
|
shutdown = async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
|
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
|
||||||
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
|
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
|
||||||
import { Monitor, MonitorType } from "@/types/monitor.js";
|
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/types/monitor.js";
|
||||||
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
|
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
|
||||||
import { ISettingsService } from "@/service/system/settingsService.js";
|
import { ISettingsService } from "@/service/system/settingsService.js";
|
||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
@@ -19,14 +19,15 @@ export class PageSpeedProvider implements IStatusProvider<PageSpeedStatusPayload
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<PageSpeedStatusPayload>> {
|
async handle(monitor: Monitor): Promise<MonitorStatusResponse<PageSpeedStatusPayload>> {
|
||||||
const { url } = monitor;
|
const { url, strategy } = monitor;
|
||||||
try {
|
try {
|
||||||
if (!url) throw new Error("URL is required for PageSpeed monitor");
|
if (!url) throw new Error("URL is required for PageSpeed monitor");
|
||||||
const dbSettings = await this.settingsService.getDBSettings();
|
const dbSettings = await this.settingsService.getDBSettings();
|
||||||
const apiKey = dbSettings?.pagespeedApiKey;
|
const apiKey = dbSettings?.pagespeedApiKey;
|
||||||
|
const resolvedStrategy = strategy ?? DefaultPageSpeedStrategy;
|
||||||
let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(
|
let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(
|
||||||
url
|
url
|
||||||
)}&category=seo&category=accessibility&category=best-practices&category=performance`;
|
)}&strategy=${encodeURIComponent(resolvedStrategy)}&category=seo&category=accessibility&category=best-practices&category=performance`;
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
pageSpeedUrl += `&key=${apiKey}`;
|
pageSpeedUrl += `&key=${apiKey}`;
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
const SERVICE_NAME = "TelegramProvider";
|
||||||
|
import type { Notification } from "@/types/index.js";
|
||||||
|
import { INotificationProvider } from "@/service/index.js";
|
||||||
|
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||||
|
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
|
||||||
|
import got from "got";
|
||||||
|
import { ILogger } from "@/utils/logger.js";
|
||||||
|
|
||||||
|
export class TelegramProvider implements INotificationProvider {
|
||||||
|
private logger: ILogger;
|
||||||
|
|
||||||
|
constructor(logger: ILogger) {
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
|
||||||
|
if (!notification.address || !notification.accessToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await got.post(`https://api.telegram.org/bot${notification.accessToken}/sendMessage`, {
|
||||||
|
json: {
|
||||||
|
chat_id: notification.address,
|
||||||
|
text: getTestMessage(),
|
||||||
|
parse_mode: "HTML",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : "unknown error";
|
||||||
|
const errStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
this.logger.warn({
|
||||||
|
message: "Telegram test alert failed",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "sendTestAlert",
|
||||||
|
stack: errStack,
|
||||||
|
details: { error: errMsg },
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
|
||||||
|
if (!notification.address || !notification.accessToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = this.buildTelegramText(message);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await got.post(`https://api.telegram.org/bot${notification.accessToken}/sendMessage`, {
|
||||||
|
json: {
|
||||||
|
chat_id: notification.address,
|
||||||
|
text,
|
||||||
|
parse_mode: "HTML",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.info({
|
||||||
|
message: "Telegram notification sent",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "sendMessage",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : "unknown error";
|
||||||
|
const errStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
this.logger.warn({
|
||||||
|
message: "Telegram alert failed",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "sendMessage",
|
||||||
|
stack: errStack,
|
||||||
|
details: { error: errMsg },
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTelegramText(message: NotificationMessage): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(`<b>${message.content.title}</b>`);
|
||||||
|
lines.push(message.content.summary);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("<b>Monitor Details:</b>");
|
||||||
|
lines.push(`• Name: ${message.monitor.name}`);
|
||||||
|
lines.push(`• URL: ${message.monitor.url}`);
|
||||||
|
lines.push(`• Type: ${message.monitor.type}`);
|
||||||
|
lines.push(`• Status: ${message.monitor.status}`);
|
||||||
|
|
||||||
|
if (message.content.details && message.content.details.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("<b>Additional Information:</b>");
|
||||||
|
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.thresholds && message.content.thresholds.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("<b>Threshold Breaches:</b>");
|
||||||
|
message.content.thresholds.forEach((breach) => {
|
||||||
|
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.incident) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`<a href="${message.clientHost}/incidents/${message.monitor.id}">View Incident</a>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export class NotificationsService implements INotificationsService {
|
|||||||
private pagerDutyProvider: INotificationProvider;
|
private pagerDutyProvider: INotificationProvider;
|
||||||
private matrixProvider: INotificationProvider;
|
private matrixProvider: INotificationProvider;
|
||||||
private teamsProvider: INotificationProvider;
|
private teamsProvider: INotificationProvider;
|
||||||
|
private telegramProvider: INotificationProvider;
|
||||||
private logger: ILogger;
|
private logger: ILogger;
|
||||||
private settingsService: ISettingsService;
|
private settingsService: ISettingsService;
|
||||||
private notificationMessageBuilder: INotificationMessageBuilder;
|
private notificationMessageBuilder: INotificationMessageBuilder;
|
||||||
@@ -47,6 +48,7 @@ export class NotificationsService implements INotificationsService {
|
|||||||
pagerDutyProvider: INotificationProvider,
|
pagerDutyProvider: INotificationProvider,
|
||||||
matrixProvider: INotificationProvider,
|
matrixProvider: INotificationProvider,
|
||||||
teamsProvider: INotificationProvider,
|
teamsProvider: INotificationProvider,
|
||||||
|
telegramProvider: INotificationProvider,
|
||||||
settingsService: ISettingsService,
|
settingsService: ISettingsService,
|
||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
notificationMessageBuilder: INotificationMessageBuilder
|
notificationMessageBuilder: INotificationMessageBuilder
|
||||||
@@ -60,6 +62,7 @@ export class NotificationsService implements INotificationsService {
|
|||||||
this.pagerDutyProvider = pagerDutyProvider;
|
this.pagerDutyProvider = pagerDutyProvider;
|
||||||
this.matrixProvider = matrixProvider;
|
this.matrixProvider = matrixProvider;
|
||||||
this.teamsProvider = teamsProvider;
|
this.teamsProvider = teamsProvider;
|
||||||
|
this.telegramProvider = telegramProvider;
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||||
@@ -97,6 +100,8 @@ export class NotificationsService implements INotificationsService {
|
|||||||
return await this.emailProvider.sendMessage!(notification, notificationMessage);
|
return await this.emailProvider.sendMessage!(notification, notificationMessage);
|
||||||
case "teams":
|
case "teams":
|
||||||
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
|
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
|
||||||
|
case "telegram":
|
||||||
|
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
|
||||||
default:
|
default:
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: `Unknown notification type: ${notification.type}`,
|
message: `Unknown notification type: ${notification.type}`,
|
||||||
@@ -157,6 +162,8 @@ export class NotificationsService implements INotificationsService {
|
|||||||
return await this.webhookProvider.sendTestAlert(notification);
|
return await this.webhookProvider.sendTestAlert(notification);
|
||||||
case "teams":
|
case "teams":
|
||||||
return await this.teamsProvider.sendTestAlert(notification);
|
return await this.teamsProvider.sendTestAlert(notification);
|
||||||
|
case "telegram":
|
||||||
|
return await this.telegramProvider.sendTestAlert(notification);
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ export class StatusService implements IStatusService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With a full window, a single raw check must not change monitor.status; only the sliding-window threshold can trigger a transition.
|
||||||
|
newStatus = monitor.status;
|
||||||
|
|
||||||
// Check if threshold has been met
|
// Check if threshold has been met
|
||||||
const failures = monitor.statusWindow.filter((s) => s === false).length;
|
const failures = monitor.statusWindow.filter((s) => s === false).length;
|
||||||
const failureRate = (failures / monitor.statusWindow.length) * 100;
|
const failureRate = (failures / monitor.statusWindow.length) * 100;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ISettingsRepository } from "@/repositories/index.js";
|
import { ISettingsRepository } from "@/repositories/index.js";
|
||||||
import { Settings, SettingsUpdate } from "@/types/index.js";
|
import { type DbType, Settings, SettingsUpdate } from "@/types/index.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { ValidatedEnv } from "@/validation/envValidation.js";
|
import { ValidatedEnv } from "@/validation/envValidation.js";
|
||||||
import type { StringValue } from "ms";
|
import type { StringValue } from "ms";
|
||||||
@@ -12,6 +12,7 @@ export type EnvConfig = {
|
|||||||
logLevel: string;
|
logLevel: string;
|
||||||
clientHost: string;
|
clientHost: string;
|
||||||
dbConnectionString: string;
|
dbConnectionString: string;
|
||||||
|
dbType: DbType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ISettingsService {
|
export interface ISettingsService {
|
||||||
@@ -25,10 +26,9 @@ export interface ISettingsService {
|
|||||||
export class SettingsService implements ISettingsService {
|
export class SettingsService implements ISettingsService {
|
||||||
static SERVICE_NAME = SERVICE_NAME;
|
static SERVICE_NAME = SERVICE_NAME;
|
||||||
private settings: EnvConfig;
|
private settings: EnvConfig;
|
||||||
private settingsRepository: ISettingsRepository;
|
private settingsRepository: ISettingsRepository | null = null;
|
||||||
|
|
||||||
constructor(settingsRepository: ISettingsRepository, env: ValidatedEnv) {
|
constructor(env: ValidatedEnv) {
|
||||||
this.settingsRepository = settingsRepository;
|
|
||||||
this.settings = {
|
this.settings = {
|
||||||
jwtSecret: env.JWT_SECRET,
|
jwtSecret: env.JWT_SECRET,
|
||||||
jwtTTL: env.TOKEN_TTL as StringValue,
|
jwtTTL: env.TOKEN_TTL as StringValue,
|
||||||
@@ -36,9 +36,14 @@ export class SettingsService implements ISettingsService {
|
|||||||
logLevel: env.LOG_LEVEL,
|
logLevel: env.LOG_LEVEL,
|
||||||
clientHost: env.CLIENT_HOST,
|
clientHost: env.CLIENT_HOST,
|
||||||
dbConnectionString: env.DB_CONNECTION_STRING,
|
dbConnectionString: env.DB_CONNECTION_STRING,
|
||||||
|
dbType: env.DB_TYPE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRepository(settingsRepository: ISettingsRepository) {
|
||||||
|
this.settingsRepository = settingsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
get serviceName() {
|
get serviceName() {
|
||||||
return SettingsService.SERVICE_NAME;
|
return SettingsService.SERVICE_NAME;
|
||||||
}
|
}
|
||||||
@@ -54,18 +59,30 @@ export class SettingsService implements ISettingsService {
|
|||||||
return this.settings;
|
return this.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRepository(): ISettingsRepository {
|
||||||
|
if (!this.settingsRepository) {
|
||||||
|
throw new AppError({
|
||||||
|
message: "Settings repository not initialized. Call setRepository() after DB connect.",
|
||||||
|
status: 500,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.settingsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
updateDbSettings = async (newSettings: SettingsUpdate) => {
|
updateDbSettings = async (newSettings: SettingsUpdate) => {
|
||||||
return await this.settingsRepository.update(newSettings);
|
return await this.getRepository().update(newSettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
getDBSettings = async () => {
|
getDBSettings = async () => {
|
||||||
|
const repo = this.getRepository();
|
||||||
// Remove any old settings
|
// Remove any old settings
|
||||||
await this.settingsRepository.deleteLegacy();
|
await repo.deleteLegacy();
|
||||||
|
|
||||||
let settings = await this.settingsRepository.findSingleton();
|
let settings = await repo.findSingleton();
|
||||||
if (settings === null) {
|
if (settings === null) {
|
||||||
await this.settingsRepository.create({});
|
await repo.create({});
|
||||||
settings = await this.settingsRepository.findSingleton();
|
settings = await repo.findSingleton();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const DLQItemTypes = ["notification", "incident_create", "incident_resolve"] as const;
|
||||||
|
export type DLQItemType = (typeof DLQItemTypes)[number];
|
||||||
|
|
||||||
|
export const DLQItemStatuses = ["pending", "retrying", "failed"] as const;
|
||||||
|
export type DLQItemStatus = (typeof DLQItemStatuses)[number];
|
||||||
|
|
||||||
|
export interface DLQItem {
|
||||||
|
id: string;
|
||||||
|
type: DLQItemType;
|
||||||
|
status: DLQItemStatus;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
monitorId: string;
|
||||||
|
teamId: string;
|
||||||
|
retryCount: number;
|
||||||
|
maxRetries: number;
|
||||||
|
lastError: string;
|
||||||
|
nextRetryAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -14,3 +14,4 @@ export * from "@/types/incident.js";
|
|||||||
export * from "@/types/email.js";
|
export * from "@/types/email.js";
|
||||||
export * from "@/types/team.js";
|
export * from "@/types/team.js";
|
||||||
export * from "@/types/maintenanceWindow.js";
|
export * from "@/types/maintenanceWindow.js";
|
||||||
|
export * from "@/types/dlqItem.js";
|
||||||
@@ -6,6 +6,10 @@ export type { GeoContinent } from "@/types/geoCheck.js";
|
|||||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
|
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
|
||||||
export type MonitorType = (typeof MonitorTypes)[number];
|
export type MonitorType = (typeof MonitorTypes)[number];
|
||||||
|
|
||||||
|
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
||||||
|
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
||||||
|
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
|
||||||
|
|
||||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||||
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
|
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
|
||||||
|
|
||||||
@@ -49,6 +53,7 @@ export interface Monitor {
|
|||||||
selectedDisks: string[];
|
selectedDisks: string[];
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
grpcServiceName?: string;
|
grpcServiceName?: string;
|
||||||
|
strategy?: PageSpeedStrategy;
|
||||||
group: string | null;
|
group: string | null;
|
||||||
geoCheckEnabled?: boolean;
|
geoCheckEnabled?: boolean;
|
||||||
geoCheckLocations?: GeoContinent[];
|
geoCheckLocations?: GeoContinent[];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as const;
|
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as const;
|
||||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export const DbTypes = ["timescaledb", "mongodb"] as const;
|
||||||
|
export type DbType = (typeof DbTypes)[number];
|
||||||
|
|
||||||
export interface SettingsThresholds {
|
export interface SettingsThresholds {
|
||||||
cpu?: number;
|
cpu?: number;
|
||||||
memory?: number;
|
memory?: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { DbTypes } from "@/types/settings.js";
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
// Server Configuration
|
// Server Configuration
|
||||||
@@ -8,6 +9,7 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
// Database
|
// Database
|
||||||
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
|
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
|
||||||
|
DB_TYPE: z.enum(DbTypes).default("mongodb"),
|
||||||
|
|
||||||
// JWT Authentication
|
// JWT Authentication
|
||||||
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
|
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { booleanCoercion } from "./shared.js";
|
|||||||
|
|
||||||
export const getIncidentsByTeamQueryValidation = z.object({
|
export const getIncidentsByTeamQueryValidation = z.object({
|
||||||
sortOrder: z.enum(["asc", "desc"]),
|
sortOrder: z.enum(["asc", "desc"]),
|
||||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "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(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { booleanCoercion } from "./shared.js";
|
import { booleanCoercion } from "./shared.js";
|
||||||
import { GeoContinents } from "@/types/geoCheck.js";
|
import { GeoContinents } from "@/types/geoCheck.js";
|
||||||
import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js";
|
import { MonitorMatchMethods, MonitorTypes, PageSpeedStrategies, DefaultPageSpeedStrategy } from "@/types/monitor.js";
|
||||||
|
|
||||||
export const getMonitorByIdParamValidation = z.object({
|
export const getMonitorByIdParamValidation = z.object({
|
||||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||||
@@ -73,6 +73,7 @@ export const createMonitorBodyValidation = z.object({
|
|||||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||||
|
strategy: z.enum(PageSpeedStrategies).optional(),
|
||||||
selectedDisks: z.array(z.string()).optional(),
|
selectedDisks: z.array(z.string()).optional(),
|
||||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
||||||
geoCheckEnabled: z.boolean().optional(),
|
geoCheckEnabled: z.boolean().optional(),
|
||||||
@@ -102,6 +103,7 @@ export const editMonitorBodyValidation = z.object({
|
|||||||
tempAlertThreshold: z.number().optional(),
|
tempAlertThreshold: z.number().optional(),
|
||||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||||
grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
|
grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
|
||||||
|
strategy: z.enum(PageSpeedStrategies).optional(),
|
||||||
selectedDisks: z.array(z.string()).optional(),
|
selectedDisks: z.array(z.string()).optional(),
|
||||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
||||||
geoCheckEnabled: z.boolean().optional(),
|
geoCheckEnabled: z.boolean().optional(),
|
||||||
@@ -156,6 +158,7 @@ const importedMonitorSchema = z.object({
|
|||||||
selectedDisks: z.array(z.string()).default([]),
|
selectedDisks: z.array(z.string()).default([]),
|
||||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||||
|
strategy: z.enum(PageSpeedStrategies).default(DefaultPageSpeedStrategy),
|
||||||
group: z.union([z.string().max(50).trim(), z.null()]).default(null),
|
group: z.union([z.string().max(50).trim(), z.null()]).default(null),
|
||||||
geoCheckEnabled: z.boolean().default(false),
|
geoCheckEnabled: z.boolean().default(false),
|
||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
|||||||
type: z.literal("teams"),
|
type: z.literal("teams"),
|
||||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||||
}),
|
}),
|
||||||
|
// Telegram notification
|
||||||
|
z.object({
|
||||||
|
notificationName: z.string().min(1, "Notification name is required"),
|
||||||
|
type: z.literal("telegram"),
|
||||||
|
address: z.string().min(1, "Chat ID is required"),
|
||||||
|
accessToken: z.string().min(1, "Bot token is required"),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const testNotificationBodyValidation = createNotificationBodyValidation;
|
export const testNotificationBodyValidation = createNotificationBodyValidation;
|
||||||
|
|||||||
Reference in new issue
Block a user